feat(app): add an experimental customizable sessions sidebar (#63143)

This commit is contained in:
usrnk1 2026-11-10 19:24:54 +01:00 • committed by GitHub
parent 23a40da6c9
commit a11e578235
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1801 additions and 107 deletions

View file

@ -2,6 +2,7 @@ import { expect, test, type Page } from "@playwright/test"
import {
REMOTE_SERVER,
SERVER,
T0,
expectPath,
holdRoute,
project,
@ -386,3 +387,137 @@ test("a project's auto-accept choice overrides the default in both directions",
await page.getByRole("option", { name: "Use default (On)" }).click()
await expect.poll(() => replies).toEqual(["permission-api", "permission-web"])
})
test("customized vertical sidebar merges a project across servers and narrows it with the view menu", async ({
page,
}) => {
// The same repo on two machines shares its project ID, so both servers' sessions share one group.
const shared = (directory: string) => project({ id: "proj_shared", directory, name: "shared" })
const local = session({ id: "ses_local_open", directory: directoryA, title: "Local open", projectID: "proj_shared" })
const recent = session({
id: "ses_local_recent",
directory: directoryA,
title: "Local recent",
projectID: "proj_shared",
created: T0 - 60_000,
})
const remote = session({
id: "ses_remote_open",
directory: directoryB,
title: "Remote open",
projectID: "proj_shared",
})
const config = (directory: string, sessions: ReturnType<typeof session>[]) => ({
directory,
project: shared(directory),
provider: { all: [], connected: [], default: {} },
sessions,
pageMessages: () => ({ items: [] }),
})
await mockServers(page, {
[SERVER]: config(directoryA, [local, recent]),
[REMOTE_SERVER]: config(directoryB, [remote]),
})
await seed(page, {
servers: [REMOTE_SERVER],
tabs: [local.id, { session: remote.id, server: REMOTE_SERVER }],
settings: { appearance: { tabLayout: "vertical" }, general: { sidebarOrganize: "customize" } },
})
await page.goto(sessionHref(local.id))
await expectSessionTitle(page, local.title)
const sidebar = page.locator('[data-slot="vertical-tabs"]')
const groups = sidebar.locator("[data-sidebar-group]")
const titles = sidebar.locator("[data-titlebar-tab-slot] [data-titlebar-tab-title]")
await expect(groups).toHaveText([/shared$/])
await expect(titles).toHaveText([local.title, remote.title])
// Only the tab from the other machine names its server.
await expect(sidebar.locator('[data-slot="tab-label"]')).toHaveCount(1)
await expect(sidebar.getByRole("button", { name: recent.title })).toBeVisible()
// Collapsing the group that holds the current session keeps only that session below it.
const header = groups.first().locator("button[aria-expanded]")
await header.click()
await expect(header).toHaveAttribute("aria-expanded", "false")
// Only a collapsed group counts its open tabs.
await expect(groups).toHaveText([/shared\s*2$/])
await expect(titles).toHaveText([local.title])
await expect(sidebar.getByRole("button", { name: recent.title })).toHaveCount(0)
await header.click()
await expect(titles).toHaveText([local.title, remote.title])
const menu = page.getByRole("menu")
await page.getByRole("button", { name: "View options" }).click()
// Moving between submenu triggers swaps the open submenu instead of stacking them.
for (const [name, item] of [
[/^Group by/, "Server → Project"],
[/^Sort by/, "Attention"],
] as const) {
const box = await menu.getByRole("menuitem", { name }).boundingBox()
if (!box) throw new Error(`no ${name} trigger`)
await page.mouse.move(box.x + 40, box.y + box.height / 2, { steps: 12 })
await expect(menu.last()).toContainText(item)
}
await expect(menu).toHaveCount(2)
await menu.getByRole("menuitem", { name: /^Group by/ }).click()
await page.getByRole("menuitemradio", { name: "Server → Project" }).click()
await page.keyboard.press("Escape")
await expect(groups).toHaveCount(4)
await expect(sidebar.locator('[data-slot="tab-label"]')).toHaveCount(0)
// A server filter hides the other server's rows, but never the active tab.
await page.getByRole("button", { name: "View options" }).click()
await page.getByRole("menuitem", { name: /^Server/ }).click()
await page.getByRole("menuitemcheckbox").last().click()
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
await expect(titles).toHaveText([local.title, remote.title])
await expect(sidebar.getByRole("button", { name: recent.title })).toHaveCount(0)
await page.getByRole("button", { name: "View options" }).click()
await menu.getByRole("menuitem", { name: "Reset filters" }).click()
await expect(sidebar.getByRole("button", { name: recent.title })).toBeVisible()
})
test("deleting a session's tab in the customized sidebar removes it from the list too", async ({ page }) => {
const first = session({ id: "ses_delete_first", directory: directoryA, title: "Delete me" })
const second = session({ id: "ses_delete_second", directory: directoryA, title: "Keep me" })
await mockServers(page, {
[SERVER]: {
directory: directoryA,
project: project({ id: "proj_delete", directory: directoryA }),
provider: { all: [], connected: [], default: {} },
sessions: [first, second],
pageMessages: () => ({ items: [] }),
},
})
await seed(page, {
tabs: [first.id, second.id],
settings: { appearance: { tabLayout: "vertical" }, general: { sidebarOrganize: "customize" } },
})
await page.goto(sessionHref(first.id))
await expectSessionTitle(page, first.title)
const sidebar = page.locator('[data-slot="vertical-tabs"]')
await sidebar.locator("[data-titlebar-tab-title]", { hasText: first.title }).click({ button: "right" })
await page.getByRole("menuitem", { name: "Delete…", exact: true }).click()
await page.getByRole("dialog").getByRole("button", { name: "Delete session", exact: true }).click()
await expectSessionTitle(page, second.title)
// The session the app had loaded must not come back as a recent row, which would open a missing session.
await expect(sidebar.getByText(first.title)).toHaveCount(0)
})

View file

@ -856,11 +856,42 @@ export const dict = {
"Group side tabs by project or show one list. Only applies to side tabs.",
"settings.experimental.sidebarOrganize.projects": "Projects",
"settings.experimental.sidebarOrganize.list": "One list",
"settings.experimental.sidebarOrganize.customize": "Customize",
"sidebar.organize.projects": "Projects",
"sidebar.organize.openTab": "Open tab",
"sidebar.organize.showMore": "Show more",
"sidebar.organize.showLess": "Show less",
"sidebar.organize.empty": "No sessions yet",
"sidebar.view.sessions": "Sessions",
"sidebar.view.options": "View options",
"sidebar.view.grouping": "Group by",
"sidebar.view.grouping.none": "None",
"sidebar.view.grouping.project": "Project",
"sidebar.view.grouping.server-project": "Server → Project",
"sidebar.view.grouping.server": "Server",
"sidebar.view.ordering": "Sort by",
"sidebar.view.ordering.manual": "Manual",
"sidebar.view.ordering.updated": "Updated",
"sidebar.view.ordering.created": "Created",
"sidebar.view.ordering.attention": "Attention",
"sidebar.view.ordering.hint": "Open tabs stay above recent sessions",
"sidebar.view.show": "Show",
"sidebar.view.show.recent": "Recent sessions",
"sidebar.view.show.subagents": "Subagents",
"sidebar.view.show.serverLabels": "Server labels",
"sidebar.view.filter.status": "Status",
"sidebar.view.filter.server": "Server",
"sidebar.view.filter.project": "Project",
"sidebar.view.status.input": "Needs input",
"sidebar.view.status.running": "Running",
"sidebar.view.status.unread": "Unread",
"sidebar.view.status.failed": "Failed",
"sidebar.view.status.idle": "Idle",
"sidebar.view.project.none": "No project",
"sidebar.view.other": "Other",
"sidebar.view.resetFilters": "Reset filters",
"sidebar.view.markAllRead": "Mark all as read",
"sidebar.view.noMatch": "No other sessions match",
"settings.plugins.reload": "Reload",
"settings.plugins.status.loading": "Loading",
"settings.plugins.status.failed": "Failed",

View file

@ -35,7 +35,7 @@ export const SettingsExperimental: Component = () => {
>
<Select
data-action="settings-sidebar-organize"
options={["list", "projects"] as const}
options={["list", "projects", "customize"] as const}
current={settings.general.sidebarOrganize()}
label={(mode) => language.t(`settings.experimental.sidebarOrganize.${mode}`)}
onSelect={(mode) => mode && settings.general.setSidebarOrganize(mode)}

View file

@ -21,6 +21,8 @@ export type BackgroundActivity = Settings["general"]["backgroundActivity"]
export type TimelineWidth = Settings["general"]["timelineWidth"]
export type SidebarOrganize = Settings["general"]["sidebarOrganize"]
export type TabLayout = Settings["appearance"]["tabLayout"]
export type ReduceMotion = Settings["appearance"]["reduceMotion"]
@ -133,7 +135,7 @@ const generalSchema = Persistence.struct({
foldUserMessages: Schema.Boolean,
// The redesigned keyboard-first question dock, off until it ships.
newQuestionUI: Schema.Boolean,
sidebarOrganize: Schema.Literals(["projects", "list"]),
sidebarOrganize: Schema.Literals(["projects", "list", "customize"]),
mobileTitlebarPosition: Schema.Literals(["top", "bottom"]),
// Owned by the review plugin, which copies it out once; kept so settings rewrites cannot drop it first.
mobileDiffWrap: Persistence.optional(Schema.Boolean),
@ -427,7 +429,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("general", "newQuestionUI", value)
},
sidebarOrganize: withFallback(() => store.general?.sidebarOrganize, defaultSettings.general.sidebarOrganize),
setSidebarOrganize(value: "projects" | "list") {
setSidebarOrganize(value: SidebarOrganize) {
setStore("general", "sidebarOrganize", value)
},
mobileTitlebarPosition: withFallback(

View file

@ -26,9 +26,8 @@ export function useSessionTabAvatarState(
if (!ctx) return false
const project = projectSettingsKey(ctx.sdk.scope, ctx.data.session.get(sessionId())?.projectID)
const requests = sessionRequests(ctx, sessionId(), !!root?.(), settings.permissions.autoApproves(project))
return !!requests.permission || !!requests.form
return attention(ctx, sessionId(), !!root?.(), settings.permissions.autoApproves(project))
})
const unread = createMemo(
@ -42,12 +41,7 @@ export function useSessionTabAvatarState(
if (needsAttention()) return false
if (root?.())
return data.session
.list()
.some((session) => data.session.root(session.id) === sessionId() && sessionBusy(data, session.id))
return sessionBusy(data, sessionId())
return running(data, sessionId(), !!root?.())
})
return { unread, loading, attention: needsAttention }
@ -74,6 +68,41 @@ export function sessionRequests(ctx: ServerCtx, sessionId: string, root: boolean
}
}
export type SessionSidebarStatus = "input" | "failed" | "unread" | "running" | "idle"
// The same status the session tab avatar shows, readable for many sessions at once (to filter and sort the sidebar).
export function sessionStatus(
ctx: ServerCtx,
sessionId: string,
input: { root: boolean; autoApprove: boolean; snapshot?: SessionInfo },
): SessionSidebarStatus {
if (attention(ctx, sessionId, input.root, input.autoApprove)) return "input"
if (running(ctx.data, sessionId, input.root)) return "running"
if (ctx.notification.session.unread(sessionId, input.snapshot)) return "unread"
if ((ctx.data.session.get(sessionId) ?? input.snapshot)?.outcome === "failed") return "failed"
return "idle"
}
function attention(ctx: ServerCtx, sessionId: string, root: boolean, autoApprove: boolean) {
const requests = sessionRequests(ctx, sessionId, root, autoApprove)
return !!requests.permission || !!requests.form
}
// A root runs while any session in its family is busy.
function running(data: ServerData, sessionId: string, root: boolean) {
if (root)
return data.session
.list()
.some((session) => data.session.root(session.id) === sessionId && sessionBusy(data, session.id))
return sessionBusy(data, sessionId)
}
export type SessionRequests = ReturnType<typeof sessionRequests>
// Like the TUI, work waiting in the inbox counts as busy; parked synthetic context does not.

View file

@ -58,7 +58,7 @@ export default function Layout(props: ParentProps) {
const verticalTabs = () => preferences.appearance.tabLayout() !== "horizontal" && !mobile()
const organizedSidebar = () =>
preferences.appearance.tabLayout() === "vertical" && preferences.general.sidebarOrganize() === "projects"
preferences.appearance.tabLayout() === "vertical" && preferences.general.sidebarOrganize() !== "list"
const toggleSidebar = () => preferences.appearance.setSidebarOpen(!preferences.appearance.sidebarOpen())

View file

@ -27,6 +27,8 @@ import {
type SessionRenameDialogEffect,
type SessionRenameDialogEvent,
} from "./session-rename-dialog-machine"
import type { Project } from "@/runtime/server/types"
import { ProjectIcon } from "@/shell/layout/project-icon"
import "./tab-nav.css"
export function ProjectSessionRow(props: {
@ -34,6 +36,12 @@ export function ProjectSessionRow(props: {
session: SessionInfo
openTab: boolean
active: boolean
/** A faint trailing label, such as the server a session belongs to. */
label?: string
/** Extra nesting steps of 14px, for subagent sessions and nested groups. */
depth?: number
/** Shows the project avatar in the leading slot, where no project header names it. */
project?: Project
onOpen: () => void
}) {
const language = useLanguage()
@ -125,8 +133,16 @@ export function ProjectSessionRow(props: {
classList={{ "text-v2-text-text-base": props.active, "text-v2-text-text-faint": !props.active }}
aria-current={props.active ? "page" : undefined}
title={sessionLabel(props.session)}
style={props.depth ? { "padding-inline-start": `calc(2rem + ${props.depth * 14}px)` } : undefined}
onClick={props.onOpen}
>
<Show when={props.project && !props.openTab ? props.project : undefined}>
{(project) => (
<span class="absolute start-2 top-1/2 flex size-4 -translate-y-1/2 items-center justify-center">
<ProjectIcon project={project()} />
</span>
)}
</Show>
<Show when={props.openTab}>
<span
role="img"
@ -140,6 +156,13 @@ export function ProjectSessionRow(props: {
<span class="min-w-0 flex-1 truncate" dir="auto">
{sessionLabel(props.session)}
</span>
<Show when={props.label}>
{(label) => (
<span class="max-w-[40%] shrink-0 truncate text-[11px] leading-[var(--line-height-compact)] text-v2-text-text-faint [font-weight:400]">
{label()}
</span>
)}
</Show>
<span class="flex size-4 shrink-0 items-center justify-center">
<Show
when={status.loading()}

View file

@ -0,0 +1,717 @@
import { Schema } from "effect"
import { createMemo, For, Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { useQueries } from "@tanstack/solid-query"
import type { SessionInfo } from "@opencode/client/promise"
import { Button } from "@opencode/ui/button"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { displayName } from "@opencode/ui/project-avatar"
import { homeSessionsQuery } from "@/home/sessions/queries"
import { useLanguage } from "@/runtime/i18n/language"
import { Persistence } from "@/runtime/persistence/schema"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useGlobal, type ServerCtx } from "@/runtime/server/runtime"
import type { Project } from "@/runtime/server/types"
import { projectSettingsKey, useSettings } from "@/settings/model"
import { sessionStatus } from "@/shell/layout/project-avatar-state"
import { ProjectIcon } from "@/shell/layout/project-icon"
import { createProjectSessionActions } from "@/shell/tabs/project-sessions"
import { tabKey, useTabs, type Tab } from "@/shell/tabs/tabs"
import { ProjectSessionRow } from "./project-session-row"
import { createSidebarActivityListener } from "./sidebar-organized"
import { mergeSidebarSessions } from "./sidebar-sessions"
import type { TabStripSection } from "./tab-strip"
import { SidebarViewMenu } from "./sidebar-view-menu"
import {
SIDEBAR_GROUPINGS,
SIDEBAR_ORDERINGS,
sidebarView,
type SidebarFilterKind,
type SidebarFilters,
type SidebarGroup,
type SidebarRow,
type SidebarStatus,
type SidebarViewState,
} from "./sidebar-view"
import "./tab-nav.css"
const ViewState = Persistence.struct({
grouping: Schema.Literals(SIDEBAR_GROUPINGS),
ordering: Schema.Literals(SIDEBAR_ORDERINGS),
show: Persistence.struct({
recent: Schema.Boolean,
subagents: Schema.Boolean,
serverLabels: Schema.Boolean,
}),
collapsed: Persistence.record(Schema.Boolean),
})
const RECENT_STEP = 5
// One ungrouped list holds every project's sessions, so it shows more before "Show more".
const RECENT_STEP_UNGROUPED = 10
type Row = SidebarRow & {
conn: ServerConnection.Any | undefined
tab: Tab | undefined
session: SessionInfo | undefined
project: Project | undefined
}
type SectionFooter = { type: "group" | "row"; key: string }
type SectionPlan = {
key: string
tabs: Tab[]
/** The key of the group the header shows. */
header: string | undefined
hideAvatars: boolean
footer: SectionFooter | undefined
}
function sameList<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
return a === b || (!!a && !!b && a.length === b.length && a.every((item, index) => item === b[index]))
}
function samePlans(a: SectionPlan[], b: SectionPlan[]) {
return (
a.length === b.length &&
a.every((plan, index) => {
const other = b[index]
return (
plan.key === other.key &&
plan.header === other.header &&
plan.hideAvatars === other.hideAvatars &&
plan.footer?.type === other.footer?.type &&
plan.footer?.key === other.footer?.key &&
sameList(plan.tabs, other.tabs)
)
})
)
}
export type SidebarStripGrouping = {
sections: TabStripSection[]
tabFooter: (tab: Tab) => JSX.Element | undefined
tabLabel: (tab: Tab) => string | undefined
}
/** The "Customize" vertical sidebar: open tabs and recent sessions from every server, grouped, sorted and filtered. */
export function SidebarCustomized(props: {
currentTab: Tab | undefined
onNewSession: () => void
children: (strip: SidebarStripGrouping) => JSX.Element
}) {
const language = useLanguage()
const global = useGlobal()
const servers = useServers()
const settings = useSettings()
const tabs = useTabs()
const actions = createProjectSessionActions()
const activity = createSidebarActivityListener()
const [view, setView] = persisted(Persist.global("sidebar.view"), ViewState, {
grouping: "project",
ordering: "manual",
show: { recent: true, subagents: false, serverLabels: true },
collapsed: {},
})
// Filters narrow the list for this window only; they reset on reload.
const [local, setLocal] = createStore<{ filters: SidebarFilters; limits: Record<string, number> }>({
filters: { status: [], server: [], project: [] },
limits: {},
})
const connected = createMemo(() =>
global.servers.list().filter((conn) => {
const ctx = global.serverCtx(ServerConnection.key(conn))
return ctx?.sdk.connection.status() === "connected"
}),
)
const connOf = (server: string) => global.servers.list().find((conn) => ServerConnection.key(conn) === server)
const ctxOf = (server: string) => {
const conn = connOf(server)
return conn ? global.serverCtx(ServerConnection.key(conn)) : undefined
}
const recentQueries = useQueries(() => ({
queries: connected().map((conn) => homeSessionsQuery(conn, ctxOf(ServerConnection.key(conn)))),
}))
// Server groupings only separate anything once a second server is added; the saved choice returns with it.
const grouping = () =>
global.servers.list().length > 1 || (view.grouping !== "server" && view.grouping !== "server-project")
? view.grouping
: "project"
const state = (): SidebarViewState => ({
grouping: grouping(),
ordering: view.ordering,
show: view.show,
filters: local.filters,
collapsed: view.collapsed,
})
// Status is the one per-row read that walks the whole session store, so only work it out when it changes the view.
const needsStatus = () => view.ordering === "attention" || local.filters.status.length > 0
const status = (ctx: ServerCtx | undefined, session: SessionInfo | undefined): SidebarStatus => {
if (!ctx || !session || !needsStatus()) return "idle"
return sessionStatus(ctx, session.id, {
root: !session.parentID,
autoApprove: settings.permissions.autoApproves(projectSettingsKey(ctx.sdk.scope, session.projectID)),
snapshot: session,
})
}
const sessionRowKey = (server: string, sessionID: string) => `${server}\u0000${sessionID}`
const rows = createMemo<Row[]>(() => {
const current = props.currentTab
const isActive = (server: string, sessionID: string) =>
current?.type === "session" && current.server === server && current.sessionId === sessionID
const openRows = tabs.store.map((tab, order): Row => {
const ctx = ctxOf(tab.server)
if (tab.type === "draft") {
return {
key: tabKey(tab),
server: tab.server,
projectID: ctx?.projects.forDirectory(tab.directory)?.id,
project: ctx?.projects.forDirectory(tab.directory),
open: true,
active: current === tab,
parentKey: undefined,
status: "idle",
updated: 0,
created: 0,
order,
conn: connOf(tab.server),
tab,
session: undefined,
}
}
const session = ctx?.data.session.get(tab.sessionId)
const directory = session?.location.directory ?? tabs.info[tabKey(tab)]?.directory
const project = session
? ctx?.projects.forSession(session)
: directory
? ctx?.projects.forDirectory(directory)
: undefined
return {
key: sessionRowKey(tab.server, tab.sessionId),
server: tab.server,
projectID: project?.id,
project,
open: true,
active: isActive(tab.server, tab.sessionId),
parentKey: undefined,
status: status(ctx, session),
updated: session?.time.updated ?? 0,
created: session?.time.created ?? 0,
order,
conn: connOf(tab.server),
tab,
session,
}
})
const openKeys = new Set(openRows.map((row) => row.key))
const sessionRows = connected().flatMap((conn, index) => {
const server = ServerConnection.key(conn)
const ctx = ctxOf(server)
if (!ctx) return []
const recent = recentQueries[index]?.data?.() ?? []
// `apply` drops sessions being deleted or already deleted, which the fetched lists still hold until they refetch.
const indexed = ctx.data.session.apply(
mergeSidebarSessions(recent, ctx.data.session.list(), (id) => activity.get(server, id)),
{ activity: false },
)
const children = view.show.subagents
? ctx.data.session.list().filter((session) => !!session.parentID && session.time.archived === undefined)
: []
return [...indexed, ...children].flatMap((session): Row[] => {
const key = sessionRowKey(server, session.id)
if (openKeys.has(key)) return []
const project = ctx.projects.forSession(session)
return [
{
key,
server,
projectID: project?.id,
project,
open: false,
active: isActive(server, session.id),
parentKey: session.parentID ? sessionRowKey(server, session.parentID) : undefined,
status: status(ctx, session),
updated: session.time.updated,
created: session.time.created,
order: 0,
conn,
tab: undefined,
session,
},
]
})
})
return [...openRows, ...sessionRows]
})
const result = createMemo(() => sidebarView(rows(), state()))
const groups = createMemo(() => result().groups.flatMap((group) => [group, ...(group.children ?? [])]))
const visibleRows = () => groups().flatMap((group) => group.rows)
// A project that spans servers takes its name and icon from the current server's copy when there is one.
const projectLabels = createMemo(() => {
const byID = new Map<string, Project>()
global.servers.list().forEach((conn) => {
ctxOf(ServerConnection.key(conn))
?.projects.list()
.forEach((project) => {
if (!byID.has(project.id) || servers.current(conn)) byID.set(project.id, project)
})
})
return byID
})
const serverOptions = () =>
global.servers.list().map((conn) => ({ key: ServerConnection.key(conn), label: servers.label(conn) }))
const projectOptions = () =>
[...projectLabels().values()].map((project) => ({ id: project.id, label: displayName(project) }))
const showLabel = (row: Row, group: SidebarGroup<Row> | undefined) => {
if (!view.show.serverLabels || !row.conn) return
if (group && group.kind !== "all" && !group.spansServers) return
return servers.tag(row.conn)
}
const rowByKey = createMemo(() => new Map(visibleRows().map((row) => [row.key, row])))
const groupByKey = createMemo(() => new Map(groups().map((group) => [group.key, group])))
const groupByRow = createMemo(
() => new Map(groups().flatMap((group) => group.rows.map((row) => [row.key, group] as const))),
)
const groupOf = (key: string) => groupByRow().get(key)
// Every row whose top-level ancestor is an open tab renders under that tab; the rest render in the group's footer.
const anchorOf = (row: Row, byKey: Map<string, Row>): Row => {
const parent = row.parentKey ? byKey.get(row.parentKey) : undefined
return parent ? anchorOf(parent, byKey) : row
}
const depthOf = (row: Row, byKey: Map<string, Row>): number => {
const parent = row.parentKey ? byKey.get(row.parentKey) : undefined
return parent ? depthOf(parent, byKey) + 1 : 0
}
const openSession = (row: Row) => {
if (!row.conn || !row.session) return
actions.openProjectSession(row.conn, row.project?.worktree ?? row.session.location.directory, row.session, {
preview: true,
})
}
// Rows outside a project group carry their project's avatar, as open tabs always do; subagents follow their parent.
const avatarOf = (row: Row, group: SidebarGroup<Row> | undefined) => {
if (group?.kind === "project" || row.parentKey || !row.projectID) return
return projectLabels().get(row.projectID) ?? row.project
}
// Rows render from their key and read the live row, so a session update changes props in place instead of rebuilding them.
function SessionRowItem(rowProps: { rowKey: string; flat?: boolean }) {
const row = () => rowByKey().get(rowProps.rowKey)
const item = () => {
const value = row()
return value?.session && value.conn ? { row: value, session: value.session, conn: value.conn } : undefined
}
const group = () => groupOf(rowProps.rowKey)
return (
<Show when={item()}>
{(value) => (
<ProjectSessionRow
server={ServerConnection.key(value().conn)}
session={value().session}
openTab={false}
active={value().row.active}
label={showLabel(value().row, group())}
depth={rowProps.flat ? 0 : depthOf(value().row, rowByKey())}
project={avatarOf(value().row, group())}
onOpen={() => openSession(value().row)}
/>
)}
</Show>
)
}
// A collapsed group keeps the current session visible below its header.
const activeRecent = (group: SidebarGroup<Row>): SectionFooter | undefined => {
const row = group.rows.find((item) => item.active && !item.open)
return row ? { type: "row", key: row.key } : undefined
}
function GroupFooter(footerProps: { groupKey: string }) {
const footer = createMemo(
() => {
const group = groupByKey().get(footerProps.groupKey)
if (!group) return { keys: [], more: false, limit: 0, step: 0 }
const footerRows = group.rows.filter((row) => !anchorOf(row, rowByKey()).open)
const topLevel = footerRows.filter((row) => anchorOf(row, rowByKey()) === row)
const step = group.kind === "all" ? RECENT_STEP_UNGROUPED : RECENT_STEP
const limit = local.limits[group.key] ?? step
const shown = new Set(topLevel.slice(0, limit))
return {
keys: footerRows.filter((row) => shown.has(anchorOf(row, rowByKey()))).map((row) => row.key),
more: topLevel.length > limit,
limit,
step,
}
},
undefined,
{ equals: (a, b) => sameList(a.keys, b.keys) && a.more === b.more && a.limit === b.limit && a.step === b.step },
)
return (
<>
<For each={footer().keys}>{(key) => <SessionRowItem rowKey={key} />}</For>
<Show when={footer().more}>
<Button
variant="ghost-muted"
size="small"
class="ms-[23px] self-start"
onClick={() => setLocal("limits", footerProps.groupKey, footer().limit + footer().step)}
>
{language.t("sidebar.organize.showMore")}
</Button>
</Show>
</>
)
}
const leafPlan = (group: SidebarGroup<Row>): SectionPlan => ({
key: group.key,
tabs: group.rows.flatMap((row) => (row.open && row.tab && (!group.collapsed || row.active) ? [row.tab] : [])),
header: group.kind === "all" ? undefined : group.key,
hideAvatars: group.kind === "project",
footer: group.collapsed ? activeRecent(group) : { type: "group", key: group.key },
})
// Only the shape of the sidebar lives here. Headers and footers read their data live, so a session update that
// leaves the shape alone keeps every header and row mounted, with its hover, focus, menus and dialogs.
const plans = createMemo(
() =>
result().groups.flatMap((group): SectionPlan[] => {
if (!group.children) return [leafPlan(group)]
// Server → Project keeps every row at one inset: the server reads as a section label above its projects.
if (!group.collapsed)
return [
{ key: group.key, tabs: [], header: group.key, hideAvatars: false, footer: undefined },
...group.children.map(leafPlan),
]
const active = group.children.find((child) => child.rows.some((row) => row.active))
return [
{
key: group.key,
tabs: active ? active.rows.flatMap((row) => (row.active && row.open && row.tab ? [row.tab] : [])) : [],
header: group.key,
hideAvatars: false,
footer: active ? activeRecent(active) : undefined,
},
]
}),
undefined,
{ equals: samePlans },
)
const sections = createMemo(() =>
plans().map(
(plan): TabStripSection => ({
key: plan.key,
tabs: plan.tabs,
header: plan.header ? <GroupHeader groupKey={plan.header} /> : undefined,
hideAvatars: plan.hideAvatars,
footer:
plan.footer?.type === "group" ? (
<GroupFooter groupKey={plan.footer.key} />
) : plan.footer ? (
<SessionRowItem rowKey={plan.footer.key} flat />
) : undefined,
}),
),
)
// Row keys of the sessions that render under each open tab (its subagents).
const tabChildren = createMemo(
() => {
const out = new Map<string, string[]>()
result().groups.forEach((parent) => {
if (parent.collapsed) return
;(parent.children ?? [parent]).forEach((group) => {
if (group.collapsed) return
group.rows.forEach((row) => {
const anchor = anchorOf(row, rowByKey())
if (anchor !== row && anchor.open) out.set(anchor.key, [...(out.get(anchor.key) ?? []), row.key])
})
})
})
return out
},
undefined,
{ equals: (a, b) => a.size === b.size && [...a].every(([key, keys]) => sameList(keys, b.get(key))) },
)
const tabFooter = (tab: Tab) => {
if (tab.type !== "session") return
const children = createMemo(() => tabChildren().get(sessionRowKey(tab.server, tab.sessionId)), undefined, {
equals: sameList,
})
if (!children()) return
return <For each={children()}>{(key) => <SessionRowItem rowKey={key} />}</For>
}
const tabLabel = (tab: Tab) => {
const key = tab.type === "session" ? sessionRowKey(tab.server, tab.sessionId) : tabKey(tab)
const row = rows().find((item) => item.key === key)
return row ? showLabel(row, groupOf(key)) : undefined
}
function GroupHeader(headerProps: { groupKey: string }) {
return (
<Show when={groupByKey().get(headerProps.groupKey)}>{(group) => <GroupHeaderView group={group()} />}</Show>
)
}
function GroupHeaderView(headerProps: { group: SidebarGroup<Row> }) {
const group = () => headerProps.group
const section = () => !!group().children
const project = () => (group().kind === "project" ? projectLabels().get(group().id) : undefined)
const label = () => {
if (group().kind === "server") {
const conn = connOf(group().id)
return conn ? servers.label(conn) : group().id
}
const value = project()
return value ? displayName(value) : language.t("sidebar.view.other")
}
// A new session starts in the project on this group's server, or on the current server when it spans several.
const newSession = () => {
const value = project()
if (!value) return
const copy = (conn: ServerConnection.Any) =>
ctxOf(ServerConnection.key(conn))
?.projects.list()
.find((item) => item.id === value.id)
const owners = connected().filter((conn) => copy(conn))
const server = group().server
const conn = (server ? connOf(server) : undefined) ?? owners.find(servers.current) ?? owners[0]
if (conn) actions.openProjectNewSession(conn, copy(conn)?.worktree ?? value.worktree)
}
return (
<div
class="group/sidebar-group relative flex min-w-0 shrink-0 items-center rounded-[6px] pe-1"
classList={{
"h-7 hover:bg-v2-overlay-simple-overlay-hover has-[:focus-visible]:bg-v2-overlay-simple-overlay-hover":
!section(),
"h-6 mt-2 first:mt-0": section(),
}}
data-sidebar-group={group().key}
>
<button
type="button"
class="flex h-full min-w-0 flex-1 cursor-default items-center gap-2 rounded-[6px] bg-transparent px-2 text-start leading-[var(--line-height-compact)] focus-visible:outline-none"
classList={{
"text-[13px] text-v2-text-text-muted [font-weight:440] group-hover/sidebar-group:text-v2-text-text-base focus-visible:text-v2-text-text-base":
!section(),
"text-[12px] font-medium text-v2-text-text-faint hover:text-v2-text-text-muted focus-visible:text-v2-text-text-muted":
section(),
}}
aria-expanded={!group().collapsed}
onClick={() => setView("collapsed", group().key, !view.collapsed[group().key])}
>
<span class="flex size-4 shrink-0 items-center justify-center">
<Show
when={project()}
fallback={
<Icon
name={group().kind === "server" ? "server" : "outline-dots"}
size="small"
class="text-v2-icon-icon-muted"
/>
}
>
{(value) => <ProjectIcon project={value()} />}
</Show>
</span>
<span class="min-w-0 flex-1 truncate" dir="auto">
{label()}
</span>
</button>
<span class="relative flex size-5 shrink-0 items-center justify-center">
<Show when={group().collapsed && group().open > 0}>
<span
class="text-[11px] leading-[var(--line-height-compact)] text-v2-text-text-faint"
classList={{
"group-hover/sidebar-group:invisible group-has-[:focus-visible]/sidebar-group:invisible": !!project(),
}}
>
{group().open}
</span>
</Show>
<Show when={project()}>
<span class="hover-reveal absolute inset-0 flex items-center justify-center group-hover/sidebar-group:opacity-100 focus-within:opacity-100">
<IconButton
icon={<Icon name="edit" />}
variant="ghost-muted"
size="small"
aria-label={language.t("command.session.new")}
onClick={newSession}
/>
</span>
</Show>
</span>
</div>
)
}
const toggleFilter = (kind: SidebarFilterKind, value: string, on: boolean) =>
setLocal("filters", kind, (values: string[]) =>
on ? [...new Set([...values, value])] : values.filter((item) => item !== value),
)
// Row status is only worked out when it changes the view, so read the unread state directly.
const isUnread = (row: Row) =>
!!row.session && !!ctxOf(row.server)?.notification.session.unread(row.session.id, row.session)
const markAllRead = () =>
visibleRows().forEach((row) => {
if (row.session && isUnread(row)) ctxOf(row.server)?.notification.session.markViewed(row.session.id)
})
const anyUnread = () => visibleRows().some(isUnread)
const filterCount = () => local.filters.status.length + local.filters.server.length + local.filters.project.length
return (
<div class="flex min-h-0 flex-1 flex-col [app-region:no-drag]">
<div class="flex h-7 shrink-0 items-center gap-1.5 ps-2 pe-1">
<span class="min-w-0 flex-1 truncate text-[13px] leading-[var(--line-height-compact)] font-medium text-v2-text-text-muted">
{language.t("sidebar.view.sessions")}
</span>
<SidebarViewMenu
state={state()}
servers={serverOptions()}
projects={projectOptions()}
canMarkRead={anyUnread()}
onGrouping={(value) => setView("grouping", value)}
onOrdering={(value) => setView("ordering", value)}
onShow={(key, value) => setView("show", key, value)}
onFilter={toggleFilter}
onResetFilters={() => setLocal("filters", { status: [], server: [], project: [] })}
onMarkRead={markAllRead}
/>
<IconButton
icon={<Icon name="plus" />}
variant="ghost-muted"
size="small"
aria-label={language.t("command.session.new")}
onClick={props.onNewSession}
/>
</div>
<div class="flex min-h-0 flex-1 flex-col pt-1">
{props.children({ sections: sections(), tabFooter, tabLabel })}
<Show when={filterCount() > 0 && !visibleRows().some((row) => !row.active)}>
<div class="px-2 py-2 text-12-regular text-v2-text-text-faint">
{language.t("sidebar.view.noMatch")}{" "}
<button
type="button"
class="underline hover:text-v2-text-text-base"
onClick={() => setLocal("filters", { status: [], server: [], project: [] })}
>
{language.t("sidebar.view.resetFilters")}
</button>
</div>
</Show>
</div>
</div>
)
}

View file

@ -39,12 +39,8 @@ const SidebarState = Persistence.struct({
order: Persistence.record(Schema.mutable(Schema.Array(Schema.String))),
})
export function SidebarOrganized(props: {
currentTab: Tab | undefined
children: (highlightActive: Accessor<boolean>, onNavigate: () => void) => JSX.Element
}) {
const language = useLanguage()
/** Tracks session activity on every server, and keeps a preview tab once its session does something. */
export function createSidebarActivityListener() {
const tabs = useTabs()
const global = useGlobal()
@ -76,6 +72,19 @@ export function SidebarOrganized(props: {
})
})
return activity
}
export function SidebarOrganized(props: {
currentTab: Tab | undefined
children: (highlightActive: Accessor<boolean>, onNavigate: () => void) => JSX.Element
}) {
const language = useLanguage()
const tabs = useTabs()
const activity = createSidebarActivityListener()
const [local, setLocal] = createStore<{
projectSession: { server: ServerConnection.Key; sessionId: string } | undefined
thumbTrack: HTMLDivElement | undefined

View file

@ -0,0 +1,223 @@
import { For, Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Menu } from "@opencode/ui/menu"
import { MenuScroll } from "@opencode/ui/menu-scroll"
import { useLanguage } from "@/runtime/i18n/language"
import {
SIDEBAR_GROUPINGS,
SIDEBAR_ORDERINGS,
SIDEBAR_SHOW_KEYS,
SIDEBAR_STATUSES,
type SidebarFilterKind,
type SidebarGrouping,
type SidebarOrdering,
type SidebarShowKey,
type SidebarViewState,
} from "./sidebar-view"
type Option = { value: string; label: string }
export function SidebarViewMenu(props: {
state: SidebarViewState
servers: { key: string; label: string }[]
projects: { id: string; label: string }[]
canMarkRead: boolean
onGrouping: (value: SidebarGrouping) => void
onOrdering: (value: SidebarOrdering) => void
onShow: (key: SidebarShowKey, value: boolean) => void
onFilter: (kind: SidebarFilterKind, value: string, on: boolean) => void
onResetFilters: () => void
onMarkRead: () => void
}): JSX.Element {
const language = useLanguage()
const options = (kind: SidebarFilterKind) => filterOptions(kind, props, language.t)
const active = () => filterCount(props.state)
// Server grouping and the server filter only separate anything once there is more than one server.
const multiServer = () => props.servers.length > 1
const filterKinds = () => FILTER_KINDS.filter((kind) => kind !== "server" || multiServer())
const groupings = () =>
SIDEBAR_GROUPINGS.filter((value) => multiServer() || (value !== "server" && value !== "server-project"))
// Kobalte closes a submenu only when focus leaves it, and hovering a sibling trigger keeps focus in the parent
// menu, so without this every submenu hovered stays open on top of the next.
const [menu, setMenu] = createStore<{ sub: string | undefined }>({ sub: undefined })
const toggleSub = (key: string, open: boolean) =>
setMenu("sub", (current) => (open ? key : current === key ? undefined : current))
return (
<Menu gutter={4} modal={false} placement="bottom-start" onOpenChange={(open) => !open && setMenu("sub", undefined)}>
<Menu.Trigger
as={IconButton}
variant="ghost-muted"
size="small"
aria-label={language.t("sidebar.view.options")}
icon={<Icon name="outline-sliders" size="small" />}
/>
<Menu.Portal>
<Menu.Content class="min-w-[200px]">
<Menu.Sub
gutter={4}
overflowPadding={16}
overlap
open={menu.sub === "grouping"}
onOpenChange={(open) => toggleSub("grouping", open)}
>
<SubTrigger label={language.t("sidebar.view.grouping")}>
{language.t(`sidebar.view.grouping.${props.state.grouping}`)}
</SubTrigger>
<Menu.Portal>
<Menu.SubContent class="w-max min-w-[172px]">
<Menu.RadioGroup
value={props.state.grouping}
onChange={(value) => {
const next = SIDEBAR_GROUPINGS.find((item) => item === value)
if (next) props.onGrouping(next)
}}
>
<For each={groupings()}>
{(value) => (
<Menu.RadioItem value={value}>{language.t(`sidebar.view.grouping.${value}`)}</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Menu.SubContent>
</Menu.Portal>
</Menu.Sub>
<Menu.Sub
gutter={4}
overflowPadding={16}
overlap
open={menu.sub === "ordering"}
onOpenChange={(open) => toggleSub("ordering", open)}
>
<SubTrigger label={language.t("sidebar.view.ordering")}>
{language.t(`sidebar.view.ordering.${props.state.ordering}`)}
</SubTrigger>
<Menu.Portal>
<Menu.SubContent class="w-max min-w-[172px] max-w-[260px]">
<Menu.RadioGroup
value={props.state.ordering}
onChange={(value) => {
const next = SIDEBAR_ORDERINGS.find((item) => item === value)
if (next) props.onOrdering(next)
}}
>
<For each={SIDEBAR_ORDERINGS}>
{(value) => (
<Menu.RadioItem value={value}>{language.t(`sidebar.view.ordering.${value}`)}</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
<Menu.Separator />
<div class="px-3 py-1.5 text-[11px] leading-4 text-v2-text-text-faint">
{language.t("sidebar.view.ordering.hint")}
</div>
</Menu.SubContent>
</Menu.Portal>
</Menu.Sub>
<Menu.Sub
gutter={4}
overflowPadding={16}
overlap
open={menu.sub === "show"}
onOpenChange={(open) => toggleSub("show", open)}
>
<SubTrigger label={language.t("sidebar.view.show")} />
<Menu.Portal>
<Menu.SubContent class="w-max min-w-[172px]">
<For each={SIDEBAR_SHOW_KEYS}>
{(key) => (
<Menu.CheckboxItem checked={props.state.show[key]} onChange={(value) => props.onShow(key, value)}>
{language.t(`sidebar.view.show.${key}`)}
</Menu.CheckboxItem>
)}
</For>
</Menu.SubContent>
</Menu.Portal>
</Menu.Sub>
<Menu.Separator />
<For each={filterKinds()}>
{(kind) => (
<Menu.Sub
gutter={4}
overflowPadding={16}
overlap
open={menu.sub === `filter:${kind}`}
onOpenChange={(open) => toggleSub(`filter:${kind}`, open)}
>
<SubTrigger label={language.t(`sidebar.view.filter.${kind}`)}>
<Show when={props.state.filters[kind].length > 0}>{props.state.filters[kind].length}</Show>
</SubTrigger>
<Menu.Portal>
<Menu.SubContent class="w-max min-w-[172px] max-w-[280px] !p-0">
<MenuScroll class="max-h-[min(20rem,50dvh)]" viewportClass="p-0.5">
<For each={options(kind)}>
{(option) => (
<Menu.CheckboxItem
checked={selectedValues(props.state, kind).includes(option.value)}
onChange={(on) => props.onFilter(kind, option.value, on)}
>
<span class="min-w-0 truncate">{option.label}</span>
</Menu.CheckboxItem>
)}
</For>
</MenuScroll>
</Menu.SubContent>
</Menu.Portal>
</Menu.Sub>
)}
</For>
<Menu.Separator />
<Menu.Item disabled={active() === 0} onSelect={props.onResetFilters}>
{language.t("sidebar.view.resetFilters")}
</Menu.Item>
<Menu.Item disabled={!props.canMarkRead} onSelect={props.onMarkRead}>
{language.t("sidebar.view.markAllRead")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
)
}
const FILTER_KINDS: readonly SidebarFilterKind[] = ["status", "server", "project"]
function SubTrigger(props: { label: string; children?: JSX.Element }) {
return (
<Menu.SubTrigger class="[&_[data-slot=menu-v2-item-chevron]]:text-v2-icon-icon-muted">
<span class="min-w-0 truncate">{props.label}</span>
<span class="ms-auto shrink-0 text-v2-text-text-faint">{props.children}</span>
</Menu.SubTrigger>
)
}
function filterOptions(
kind: SidebarFilterKind,
props: { servers: { key: string; label: string }[]; projects: { id: string; label: string }[] },
t: ReturnType<typeof useLanguage>["t"],
): Option[] {
if (kind === "status") return SIDEBAR_STATUSES.map((value) => ({ value, label: t(`sidebar.view.status.${value}`) }))
if (kind === "server") return props.servers.map((server) => ({ value: server.key, label: server.label }))
return [
...props.projects.map((project) => ({ value: project.id, label: project.label })),
{ value: "none", label: t("sidebar.view.project.none") },
]
}
function filterCount(state: SidebarViewState) {
return state.filters.status.length + state.filters.server.length + state.filters.project.length
}
function selectedValues(state: SidebarViewState, kind: SidebarFilterKind): readonly string[] {
return state.filters[kind]
}

View file

@ -0,0 +1,185 @@
import { describe, expect, test } from "bun:test"
import { sidebarView, type SidebarRow, type SidebarViewState } from "./sidebar-view"
function row(overrides: Partial<SidebarRow> & { key: string }): SidebarRow {
return {
server: "a",
projectID: "p1",
open: true,
active: false,
parentKey: undefined,
status: "idle",
updated: 0,
created: 0,
order: 0,
...overrides,
}
}
function state(overrides: Partial<SidebarViewState> = {}): SidebarViewState {
return {
grouping: "none",
ordering: "manual",
show: { recent: true, subagents: true, serverLabels: false },
filters: { status: [], server: [], project: [] },
collapsed: {},
...overrides,
}
}
const keys = (rows: SidebarRow[], input: SidebarViewState) =>
sidebarView(rows, input).groups.flatMap((group) => group.rows.map((item) => item.key))
describe("sidebarView", () => {
const ordering = [
row({ key: "open-late", order: 2, updated: 50, created: 1, status: "running" }),
row({ key: "open-early", order: 1, updated: 10, created: 5, status: "failed" }),
row({ key: "recent-new", open: false, updated: 100, created: 3, status: "input" }),
row({ key: "recent-old", open: false, updated: 20, created: 9, status: "idle" }),
]
test.each([
["manual", ["open-early", "open-late", "recent-new", "recent-old"]],
["updated", ["open-late", "open-early", "recent-new", "recent-old"]],
["created", ["open-early", "open-late", "recent-old", "recent-new"]],
["attention", ["open-early", "open-late", "recent-new", "recent-old"]],
] as const)("orders open rows before recent ones by %s", (mode, expected) => {
expect(keys(ordering, state({ ordering: mode }))).toEqual([...expected])
})
test("attention ranks status before recency and breaks ties by updated", () => {
const rows = [
row({ key: "idle", status: "idle", updated: 9 }),
row({ key: "unread-old", status: "unread", updated: 1 }),
row({ key: "unread-new", status: "unread", updated: 5 }),
row({ key: "input", status: "input", updated: 0 }),
]
expect(keys(rows, state({ ordering: "attention" }))).toEqual(["input", "unread-new", "unread-old", "idle"])
})
test("merges a project across servers in project grouping and splits it per server", () => {
const rows = [
row({ key: "a1", server: "a", projectID: "p1", order: 0 }),
row({ key: "b1", server: "b", projectID: "p1", order: 1 }),
row({ key: "b-none", server: "b", projectID: undefined, order: 2 }),
row({ key: "a2", server: "a", projectID: "p2", order: 3 }),
]
const project = sidebarView(rows, state({ grouping: "project" })).groups
expect(project.map((group) => [group.key, group.spansServers, group.rows.map((item) => item.key)])).toEqual([
["project:p1", true, ["a1", "b1"]],
["project:p2", false, ["a2"]],
["project:other", false, ["b-none"]],
])
const nested = sidebarView(rows, state({ grouping: "server-project" })).groups
expect(
nested.map((group) => [group.key, group.open, group.rows, group.children?.map((child) => child.key)]),
).toEqual([
["server:a", 2, [], ["server:a/project:p1", "server:a/project:p2"]],
["server:b", 2, [], ["server:b/project:p1", "server:b/project:other"]],
])
expect(nested[1]?.children?.map((child) => [child.server, child.spansServers])).toEqual([
["b", false],
["b", false],
])
})
test("other project group stays last even when it holds the first row", () => {
const rows = [row({ key: "none", projectID: undefined, order: 0 }), row({ key: "p", projectID: "p9", order: 1 })]
expect(sidebarView(rows, state({ grouping: "project" })).groups.map((group) => group.key)).toEqual([
"project:p9",
"project:other",
])
})
test("places subagents after their parent in the parent's group and drops orphans", () => {
const rows = [
row({ key: "parent", order: 1, projectID: "p1" }),
row({ key: "other", order: 0, projectID: "p2" }),
row({ key: "sub-old", open: false, parentKey: "parent", projectID: "p2", updated: 1 }),
row({ key: "sub-new", open: false, parentKey: "parent", projectID: undefined, updated: 5 }),
row({ key: "grandchild", open: false, parentKey: "sub-old", updated: 0 }),
row({ key: "orphan", open: false, parentKey: "missing" }),
row({ key: "orphan-child", open: false, parentKey: "orphan" }),
]
const groups = sidebarView(rows, state({ grouping: "project" })).groups
expect(groups.map((group) => [group.key, group.open, group.rows.map((item) => item.key)])).toEqual([
["project:p2", 1, ["other"]],
["project:p1", 1, ["parent", "sub-new", "sub-old", "grandchild"]],
])
})
test("keeps an active orphan subagent as a top-level row", () => {
const rows = [row({ key: "open", order: 0 }), row({ key: "sub", open: false, parentKey: "gone", active: true })]
expect(keys(rows, state())).toEqual(["open", "sub"])
})
const matched = ["match", "active", "recent"]
test.each<[string, Partial<SidebarViewState>, string[]]>([
["status filter", { filters: { status: ["running"], server: [], project: [] } }, matched],
["server filter", { filters: { status: [], server: ["b"], project: [] } }, matched],
["project filter", { filters: { status: [], server: [], project: ["none"] } }, matched],
["hidden recent", { show: { recent: false, subagents: true, serverLabels: false } }, ["match", "miss", "active"]],
])("%s drops non-matching rows but keeps the active row", (_, overrides, expected) => {
const rows = [
row({ key: "active", open: false, active: true }),
row({ key: "match", server: "b", projectID: undefined, status: "running" }),
row({ key: "recent", open: false, server: "b", projectID: undefined, status: "running" }),
row({ key: "miss", status: "idle" }),
]
expect(keys(rows, state({ ordering: "updated", ...overrides }))).toEqual(expected)
})
test("project filter keeps rows of the named project", () => {
const rows = [row({ key: "p1" }), row({ key: "p2", projectID: "p2" })]
expect(keys(rows, state({ filters: { status: [], server: [], project: ["p2"] } }))).toEqual(["p2"])
})
test("hides subagent rows unless shown", () => {
const rows = [row({ key: "parent" }), row({ key: "sub", open: false, parentKey: "parent" })]
const hidden = { recent: true, subagents: false, serverLabels: false }
expect(keys(rows, state({ show: hidden }))).toEqual(["parent"])
expect(keys(rows, state({ show: { ...hidden, subagents: true } }))).toEqual(["parent", "sub"])
})
test("collapsed groups hide their open keys but keep the active row", () => {
const rows = [
row({ key: "a1", server: "a", projectID: "p1", order: 0 }),
row({ key: "a2", server: "a", projectID: "p2", order: 1 }),
row({ key: "b1", server: "b", projectID: "p1", order: 2 }),
row({ key: "b2", server: "b", projectID: "p2", order: 3, active: true }),
row({ key: "c1", server: "c", projectID: "p1", order: 4 }),
]
const view = sidebarView(
rows,
state({
grouping: "server-project",
collapsed: { "server:a/project:p2": true, "server:b": true, "server:c": true },
}),
)
expect(view.groups.map((group) => [group.key, group.collapsed, group.children?.length])).toEqual([
["server:a", false, 2],
["server:b", true, 2],
["server:c", true, 1],
])
expect(view.groups[0]?.children?.map((child) => child.collapsed)).toEqual([false, true])
const flat = sidebarView(
rows,
state({ grouping: "project", collapsed: { "project:p1": true, "project:p2": true } }),
)
expect(flat.groups.map((group) => [group.key, group.collapsed])).toEqual([
["project:p1", true],
["project:p2", true],
])
})
})

View file

@ -0,0 +1,208 @@
export type SidebarGrouping = "none" | "project" | "server-project" | "server"
export type SidebarOrdering = "manual" | "updated" | "created" | "attention"
export type SidebarStatus = "input" | "failed" | "unread" | "running" | "idle"
export type SidebarShow = { recent: boolean; subagents: boolean; serverLabels: boolean }
export type SidebarShowKey = keyof SidebarShow
/** A `project` value of "none" matches rows without a project. */
export type SidebarFilters = { status: SidebarStatus[]; server: string[]; project: string[] }
export type SidebarFilterKind = keyof SidebarFilters
export type SidebarViewState = {
grouping: SidebarGrouping
ordering: SidebarOrdering
show: SidebarShow
filters: SidebarFilters
collapsed: Record<string, boolean>
}
export type SidebarRow = {
key: string
server: string
projectID: string | undefined
/** An open tab, as opposed to a recent or subagent session. */
open: boolean
active: boolean
/** The row key of the parent session, for subagent rows. */
parentKey: string | undefined
status: SidebarStatus
updated: number
created: number
/** Position in the saved tab order, for open rows. */
order: number
}
export type SidebarGroup<R extends SidebarRow> = {
key: string
kind: "all" | "project" | "server"
/** The project ID, "other", the server key, or "all". */
id: string
/** The server of a server group, or of a project group nested in one. */
server: string | undefined
collapsed: boolean
/** Open top-level rows in the group, including nested groups. */
open: number
/** Leaf groups: sorted rows, subagents right after their parent. Server → Project parents: empty. */
rows: R[]
/** A project group whose rows come from more than one server. */
spansServers: boolean
children: SidebarGroup<R>[] | undefined
}
export type SidebarView<R extends SidebarRow> = {
groups: SidebarGroup<R>[]
}
export const SIDEBAR_STATUSES: readonly SidebarStatus[] = ["running", "input", "unread", "failed", "idle"]
export const SIDEBAR_GROUPINGS: readonly SidebarGrouping[] = ["none", "project", "server-project", "server"]
export const SIDEBAR_ORDERINGS: readonly SidebarOrdering[] = ["manual", "updated", "created", "attention"]
export const SIDEBAR_SHOW_KEYS: readonly SidebarShowKey[] = ["recent", "subagents", "serverLabels"]
export function sidebarView<R extends SidebarRow>(rows: readonly R[], state: SidebarViewState): SidebarView<R> {
const kept = keptRows(rows, state)
const keys = new Set(kept.map((row) => row.key))
const children = Map.groupBy(
kept.filter((row) => row.parentKey !== undefined && keys.has(row.parentKey)),
(row) => row.parentKey,
)
const compare = comparator(state.ordering)
const top = kept
.filter((row) => row.parentKey === undefined || !keys.has(row.parentKey))
.toSorted((a, b) => Number(b.open) - Number(a.open) || compare(a, b) || byKey(a, b))
const withChildren = (row: R): R[] => [
row,
...(children.get(row.key) ?? []).toSorted((a, b) => b.updated - a.updated || byKey(a, b)).flatMap(withChildren),
]
return { groups: buildGroups(top, withChildren, state) }
}
const ATTENTION: Record<SidebarStatus, number> = { input: 0, failed: 1, unread: 2, running: 3, idle: 4 }
function keptRows<R extends SidebarRow>(rows: readonly R[], state: SidebarViewState) {
const visible = rows.filter((row) => row.active || isVisible(row, state))
const byKey = new Map(visible.map((row) => [row.key, row]))
const attached = (row: R): boolean => {
if (row.active || row.parentKey === undefined) return true
const parent = byKey.get(row.parentKey)
return parent !== undefined && attached(parent)
}
return visible.filter(attached)
}
function isVisible(row: SidebarRow, state: SidebarViewState) {
if (row.parentKey !== undefined && !state.show.subagents) return false
if (!row.open && row.parentKey === undefined && !state.show.recent) return false
const filters = state.filters
if (filters.status.length > 0 && !filters.status.includes(row.status)) return false
if (filters.server.length > 0 && !filters.server.includes(row.server)) return false
return filters.project.length === 0 || filters.project.includes(row.projectID ?? "none")
}
function comparator(ordering: SidebarOrdering): (a: SidebarRow, b: SidebarRow) => number {
if (ordering === "manual") return (a, b) => (a.open && b.open ? a.order - b.order : b.updated - a.updated)
if (ordering === "created") return (a, b) => b.created - a.created
if (ordering === "attention") return (a, b) => ATTENTION[a.status] - ATTENTION[b.status] || b.updated - a.updated
return (a, b) => b.updated - a.updated
}
function byKey(a: SidebarRow, b: SidebarRow) {
if (a.key === b.key) return 0
return a.key < b.key ? -1 : 1
}
type Bucket<R> = {
key: string
kind: SidebarGroup<SidebarRow>["kind"]
id: string
server: string | undefined
rows: R[]
}
function buildGroups<R extends SidebarRow>(top: R[], withChildren: (row: R) => R[], state: SidebarViewState) {
const collapsed = state.collapsed
if (state.grouping === "none") {
return [leaf({ key: "all", kind: "all", id: "all", server: undefined, rows: top }, withChildren, collapsed)]
}
if (state.grouping === "project") {
return projectBuckets(top, "", undefined).map((bucket) => leaf(bucket, withChildren, collapsed))
}
const servers = [...Map.groupBy(top, (row) => row.server)]
if (state.grouping === "server") {
return servers.map(([server, rows]) =>
leaf({ key: `server:${server}`, kind: "server", id: server, server, rows }, withChildren, collapsed),
)
}
return servers.map(([server, rows]): SidebarGroup<R> => {
const key = `server:${server}`
const nested = projectBuckets(rows, `${key}/`, server).map((bucket) => leaf(bucket, withChildren, collapsed))
return {
key,
kind: "server",
id: server,
server,
collapsed: !!collapsed[key],
open: nested.reduce((sum, group) => sum + group.open, 0),
rows: [],
spansServers: false,
children: nested,
}
})
}
/** Project buckets in first-row order, with rows lacking a project in a trailing "other" bucket. */
function projectBuckets<R extends SidebarRow>(rows: R[], prefix: string, server: string | undefined): Bucket<R>[] {
return [...Map.groupBy(rows, (row) => row.projectID)]
.toSorted(([a], [b]) => Number(a === undefined) - Number(b === undefined))
.map(([projectID, rows]) => {
const id = projectID ?? "other"
return { key: `${prefix}project:${id}`, kind: "project", id, server, rows }
})
}
function leaf<R extends SidebarRow>(
bucket: Bucket<R>,
withChildren: (row: R) => R[],
collapsed: Record<string, boolean>,
): SidebarGroup<R> {
const rows = bucket.rows.flatMap(withChildren)
return {
...bucket,
rows,
collapsed: !!collapsed[bucket.key],
open: bucket.rows.filter((row) => row.open).length,
spansServers: bucket.kind === "project" && new Set(rows.map((row) => row.server)).size > 1,
children: undefined,
}
}

View file

@ -462,6 +462,15 @@
}
}
/* Under a project header the avatar is left out, but shortcut numbers still show in its slot. */
[data-titlebar-tab][data-avatar="hidden"] [data-slot="project-avatar-slot"] {
visibility: hidden;
}
[data-shortcut-hints] [data-titlebar-tab][data-avatar="hidden"] [data-slot="project-avatar-slot"] {
visibility: visible;
}
/* Holding the mod key slides each tab's initial out as its shortcut number rises in. */
[data-titlebar-tab]
:is(

View file

@ -72,6 +72,10 @@ export function TabNavItem(
attention: boolean
loading: boolean
shortcut?: number
/** A faint trailing label, such as the tab's server. */
label?: string
/** Hides the project avatar under a header that already names the project; shortcut hints still show. */
hideAvatar?: boolean
fallbackTitle?: string
fallbackDirectory?: string
/** Shows a title at once: the optimistic one, or the one to restore when its save fails. */
@ -401,6 +405,7 @@ export function TabNavItem(
data-orientation={props.orientation ?? "horizontal"}
data-title-overflow={titleOverflowing()}
data-editing={editing()}
data-avatar={props.hideAvatar ? "hidden" : undefined}
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] px-1.5 [container-type:inline-size]"
classList={{ invisible: props.hidden }}
data-active={props.active}
@ -520,6 +525,16 @@ export function TabNavItem(
}}
/>
</Menu.Context.Trigger>
<Show when={props.label}>
{(label) => (
<span
data-slot="tab-label"
class="me-7 max-w-[40%] shrink-0 truncate text-[11px] leading-[var(--line-height-compact)] text-v2-text-text-faint"
>
{label()}
</span>
)}
</Show>
<div data-slot="tab-close">
<span data-slot="tab-status" aria-hidden="true">
@ -619,6 +634,8 @@ export function DraftTabItem(
href: string
title: string
shortcut?: number
/** A faint trailing label, such as the tab's server. */
label?: string
active?: boolean
onNavigate: () => void
suppressNavigation?: boolean
@ -724,6 +741,16 @@ export function DraftTabItem(
{props.title}
</span>
</Menu.Context.Trigger>
<Show when={props.label}>
{(label) => (
<span
data-slot="tab-label"
class="me-7 max-w-[40%] shrink-0 truncate text-[11px] leading-[var(--line-height-compact)] text-v2-text-text-faint"
>
{label()}
</span>
)}
</Show>
<div data-slot="tab-close">
<IconButton
size="small"

View file

@ -4,12 +4,14 @@ import {
createMemo,
createSignal,
For,
type JSX,
onCleanup,
onMount,
Show,
untrack,
} from "solid-js"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Key } from "@solid-primitives/keyed"
import { createStore } from "solid-js/store"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
@ -76,6 +78,10 @@ function SessionTabSlot(
tab: SessionTab
id: string
index: number
group?: string
footer?: JSX.Element
label?: string
hideAvatar?: boolean
active: boolean
orientation: "horizontal" | "vertical"
session: SessionInfo | undefined
@ -100,6 +106,13 @@ function SessionTabSlot(
get index() {
return props.index
},
// Rows only sort among rows of the same group, so a drag never crosses into another section.
get type() {
return props.group
},
get accept() {
return props.group
},
})
let ref!: HTMLDivElement
@ -115,6 +128,7 @@ function SessionTabSlot(
classList={{
"w-56 min-w-7 max-w-56 flex-shrink": props.orientation === "horizontal",
"w-full shrink-0": props.orientation === "vertical",
"flex-col": !!props.footer,
}}
onPointerEnter={() => props.onIntent(true)}
onPointerLeave={() => props.onIntent(false)}
@ -131,6 +145,8 @@ function SessionTabSlot(
attention={props.attention}
loading={props.loading}
shortcut={props.shortcut}
label={props.label}
hideAvatar={props.hideAvatar}
fallbackTitle={props.fallbackTitle}
fallbackDirectory={props.fallbackDirectory}
onRememberTitle={props.onRememberTitle}
@ -144,6 +160,7 @@ function SessionTabSlot(
dragging={sortable.isDragSource()}
orientation={props.orientation}
/>
{props.footer}
</div>
)
}
@ -154,6 +171,10 @@ function SessionTabEntry(
currentTab?: Tab
id: string
index: number
group?: string
footer?: JSX.Element
label?: string
hideAvatar?: boolean
active: boolean
highlightActive?: boolean
orientation: "horizontal" | "vertical"
@ -325,6 +346,10 @@ function SessionTabEntry(
tab={props.tab}
id={props.id}
index={props.index}
group={props.group}
footer={props.footer}
label={props.label}
hideAvatar={props.hideAvatar}
active={props.active && props.highlightActive !== false}
orientation={props.orientation}
session={session()}
@ -360,6 +385,9 @@ function DraftTabSlot(
tab: Extract<Tab, { type: "draft" }>
id: string
index: number
group?: string
footer?: JSX.Element
label?: string
active: boolean
orientation: "horizontal" | "vertical"
title: string
@ -374,6 +402,13 @@ function DraftTabSlot(
get index() {
return props.index
},
// Rows only sort among rows of the same group, so a drag never crosses into another section.
get type() {
return props.group
},
get accept() {
return props.group
},
})
let ref!: HTMLDivElement
@ -389,6 +424,7 @@ function DraftTabSlot(
classList={{
"w-56 min-w-7 max-w-56 flex-shrink": props.orientation === "horizontal",
"w-full shrink-0": props.orientation === "vertical",
"flex-col": !!props.footer,
}}
>
<DraftTabItem
@ -398,6 +434,7 @@ function DraftTabSlot(
href={tabHref(props.tab)}
title={props.title}
shortcut={props.shortcut}
label={props.label}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
onCloseOthers={props.onCloseOthers}
@ -406,14 +443,31 @@ function DraftTabSlot(
dragging={sortable.isDragSource()}
orientation={props.orientation}
/>
{props.footer}
</div>
)
}
export type TabStripSection = {
key: string
/** Open tabs shown in this section, in display order. */
tabs: Tab[]
header?: JSX.Element
footer?: JSX.Element
/** The header already names the project, so session tabs leave out their project avatar. */
hideAvatars?: boolean
}
export function TitlebarTabStrip(props: {
orientation?: "horizontal" | "vertical"
highlightActive?: boolean
tabs: Tab[]
/** Vertical only: groups the rendered tabs under headers. Tabs in no section stay open but are not shown. */
sections?: TabStripSection[]
/** Vertical only: extra content rendered right after a tab row (e.g. its subagent sessions). */
tabFooter?: (tab: Tab) => JSX.Element | undefined
/** Vertical only: a faint trailing label on a tab row (e.g. its server). */
tabLabel?: (tab: Tab) => string | undefined
currentTab: Tab | undefined
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
onClose: (tab: Tab) => void
@ -425,6 +479,14 @@ export function TitlebarTabStrip(props: {
const command = useCommand()
const appTabs = useTabs()
const vertical = () => props.orientation === "vertical"
const sections = () => (vertical() ? props.sections : undefined)
const displayTabs = createMemo(() => sections()?.flatMap((section) => section.tabs) ?? props.tabs)
const sectionOf = createMemo(
() => new Map(sections()?.flatMap((section) => section.tabs.map((tab) => [tabKey(tab), section.key] as const))),
)
let listRef!: HTMLDivElement
let scrollRef!: HTMLDivElement
// Each vertical edge feathers by as much as there is left to scroll past it, so it appears and leaves without a jump.
@ -551,7 +613,7 @@ export function TitlebarTabStrip(props: {
})
const visibleTabs = createMemo(() =>
props.tabs.filter((tab) => tab.type === "draft" || !!views().get(tabKey(tab))?.visible),
displayTabs().filter((tab) => tab.type === "draft" || !!views().get(tabKey(tab))?.visible),
)
const visibleTabIds = () => visibleTabs().map(tabKey)
@ -690,6 +752,89 @@ export function TitlebarTabStrip(props: {
if (next) props.onNavigate(next)
}
function renderTab(tab: Tab, section?: TabStripSection) {
const id = tabKey(tab)
const group = section?.key
let ref!: HTMLDivElement
const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id)
const others = () => visibleTabs().filter((item) => tabKey(item) !== id)
const after = () => visibleTabs().slice(visibleIndex() + 1)
const closeOthers = () => props.onCloseTabs(others(), tab)
const closeAfter = () => props.onCloseTabs(after(), tab)
const element = () => ref
tabRefs.set(id, element)
onCleanup(() => {
if (tabRefs.get(id) === element) tabRefs.delete(id)
})
const serverCtx = useServerCtx(() => {
if (tab.type !== "session") return
return global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
})
const footer = createMemo(() => (vertical() ? props.tabFooter?.(tab) : undefined))
const label = () => (vertical() ? props.tabLabel?.(tab) : undefined)
if (tab.type === "session") {
return (
<SessionTabEntry
tab={tab}
currentTab={props.currentTab}
id={id}
index={visibleIndex()}
group={group}
footer={footer()}
label={label()}
hideAvatar={section?.hideAvatars}
active={props.currentTab === tab}
highlightActive={props.highlightActive}
orientation={vertical() ? "vertical" : "horizontal"}
serverCtx={serverCtx()}
view={views().get(id) ?? hiddenTabView}
shortcut={shortcut(visibleIndex())}
onUnreadChange={(value) => setUnread(id, value)}
onNavigate={(element) => {
ref = element
props.onNavigate(tab, element)
}}
onClose={() => props.onClose(tab)}
onCloseOthers={others().length ? closeOthers : undefined}
onCloseAfter={after().length ? closeAfter : undefined}
/>
)
}
return (
<DraftTabSlot
tab={tab}
id={id}
index={visibleIndex()}
group={group}
footer={footer()}
label={label()}
active={props.currentTab === tab && props.highlightActive !== false}
orientation={vertical() ? "vertical" : "horizontal"}
title={language.t("session.tab.session")}
shortcut={shortcut(visibleIndex())}
onNavigate={(element) => {
ref = element
props.onNavigate(tab, element)
}}
onClose={() => props.onClose(tab)}
onCloseOthers={others().length ? closeOthers : undefined}
onCloseAfter={after().length ? closeAfter : undefined}
/>
)
}
return (
<div
data-slot={vertical() ? "vertical-tabs" : "titlebar-tabs"}
@ -758,15 +903,12 @@ export function TitlebarTabStrip(props: {
const { initialIndex, index } = source
if (initialIndex !== index) {
props.onReorder(
mergeVisibleTabOrder(
props.tabs.map(tabKey),
current,
arrayMove(current, source.initialIndex, source.index),
),
)
}
if (initialIndex === index) return
const next = arrayMove(current, initialIndex, index)
// Sections are contiguous, so a move within one keeps every position's section.
if (sections() && next.some((key, i) => sectionOf().get(key) !== sectionOf().get(current[i]))) return
props.onReorder(mergeVisibleTabOrder(props.tabs.map(tabKey), current, next))
}}
>
<div
@ -776,77 +918,19 @@ export function TitlebarTabStrip(props: {
classList={{ "flex-row items-center": !vertical(), "flex-col items-stretch": vertical() }}
ref={listRef}
>
<For each={props.tabs}>
{(tab) => {
const id = tabKey(tab)
let ref!: HTMLDivElement
const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id)
const others = () => visibleTabs().filter((item) => tabKey(item) !== id)
const after = () => visibleTabs().slice(visibleIndex() + 1)
const closeOthers = () => props.onCloseTabs(others(), tab)
const closeAfter = () => props.onCloseTabs(after(), tab)
const element = () => ref
tabRefs.set(id, element)
onCleanup(() => {
if (tabRefs.get(id) === element) tabRefs.delete(id)
})
const serverCtx = useServerCtx(() => {
if (tab.type !== "session") return
return global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
})
if (tab.type === "session") {
return (
<SessionTabEntry
tab={tab}
currentTab={props.currentTab}
id={id}
index={visibleIndex()}
active={props.currentTab === tab}
highlightActive={props.highlightActive}
orientation={vertical() ? "vertical" : "horizontal"}
serverCtx={serverCtx()}
view={views().get(id) ?? hiddenTabView}
shortcut={shortcut(visibleIndex())}
onUnreadChange={(value) => setUnread(id, value)}
onNavigate={(element) => {
ref = element
props.onNavigate(tab, element)
}}
onClose={() => props.onClose(tab)}
onCloseOthers={others().length ? closeOthers : undefined}
onCloseAfter={after().length ? closeAfter : undefined}
/>
)
}
return (
<DraftTabSlot
tab={tab}
id={id}
index={visibleIndex()}
active={props.currentTab === tab && props.highlightActive !== false}
orientation={vertical() ? "vertical" : "horizontal"}
title={language.t("session.tab.session")}
shortcut={shortcut(visibleIndex())}
onNavigate={(element) => {
ref = element
props.onNavigate(tab, element)
}}
onClose={() => props.onClose(tab)}
onCloseOthers={others().length ? closeOthers : undefined}
onCloseAfter={after().length ? closeAfter : undefined}
/>
)
}}
</For>
<Show when={sections()} fallback={<For each={props.tabs}>{(tab) => renderTab(tab)}</For>}>
{(list) => (
<Key each={list()} by={(section) => section.key}>
{(section) => (
<>
{section().header}
<For each={section().tabs}>{(tab) => renderTab(tab, section())}</For>
{section().footer}
</>
)}
</Key>
)}
</Show>
</div>
</DragDropProvider>
</div>

View file

@ -18,6 +18,7 @@ import { createTitlebarHistory } from "./history"
import { TitlebarTabStrip } from "@/shell/titlebar/tab-strip"
import { macVerticalTrafficLights, SidebarToggle } from "@/shell/sidebar-toggle"
import { SidebarInbox } from "@/shell/titlebar/inbox"
import { SidebarCustomized, type SidebarStripGrouping } from "@/shell/titlebar/sidebar-customized"
import { SidebarOrganized } from "@/shell/titlebar/sidebar-organized"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createMediaQuery } from "@solid-primitives/media"
@ -206,7 +207,7 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
createEffect(() => {
if (
settings.appearance.tabLayout() === "vertical" &&
settings.general.sidebarOrganize() === "projects" &&
settings.general.sidebarOrganize() !== "list" &&
!mobile()
)
return
@ -305,10 +306,17 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
const verticalTabStrip = (highlightActive?: () => boolean, onNavigate?: () => void) => (
const verticalTabStrip = (
highlightActive?: () => boolean,
onNavigate?: () => void,
grouped?: SidebarStripGrouping,
) => (
<TitlebarTabStrip
orientation="vertical"
highlightActive={highlightActive?.()}
sections={grouped?.sections}
tabFooter={grouped?.tabFooter}
tabLabel={grouped?.tabLabel}
tabs={tabsStore}
currentTab={currentTab()}
onNavigate={(tab, el) => {
@ -802,12 +810,16 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
<Show
when={settings.appearance.tabLayout() === "inbox"}
fallback={
<Show
when={settings.general.sidebarOrganize() === "projects"}
fallback={verticalTabStrip()}
>
<SidebarOrganized currentTab={currentTab()}>{verticalTabStrip}</SidebarOrganized>
</Show>
<Switch fallback={verticalTabStrip()}>
<Match when={settings.general.sidebarOrganize() === "projects"}>
<SidebarOrganized currentTab={currentTab()}>{verticalTabStrip}</SidebarOrganized>
</Match>
<Match when={settings.general.sidebarOrganize() === "customize"}>
<SidebarCustomized currentTab={currentTab()} onNewSession={openNewTab}>
{(grouped) => verticalTabStrip(undefined, undefined, grouped)}
</SidebarCustomized>
</Match>
</Switch>
}
>
<SidebarInbox />