mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 23:23:48 +00:00
feat(app): add an experimental customizable sessions sidebar (#63143)
This commit is contained in:
parent
23a40da6c9
commit
a11e578235
16 changed files with 1801 additions and 107 deletions
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
717
packages/app/src/shell/titlebar/sidebar-customized.tsx
Normal file
717
packages/app/src/shell/titlebar/sidebar-customized.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
223
packages/app/src/shell/titlebar/sidebar-view-menu.tsx
Normal file
223
packages/app/src/shell/titlebar/sidebar-view-menu.tsx
Normal 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]
|
||||
}
|
||||
185
packages/app/src/shell/titlebar/sidebar-view.test.ts
Normal file
185
packages/app/src/shell/titlebar/sidebar-view.test.ts
Normal 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],
|
||||
])
|
||||
})
|
||||
})
|
||||
208
packages/app/src/shell/titlebar/sidebar-view.ts
Normal file
208
packages/app/src/shell/titlebar/sidebar-view.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue