From 36e0b8f7ff07b1d6f900a91ea241ae0b18643da6 Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Sun, 8 Nov 2026 10:28:54 -0500 Subject: [PATCH 1/3] feat(app): tab arrangement, pinned flag and bulk close in the tab store (#2154) Pure display-order derivation (pinned first, spawned sessions after their parent) and bulk-close selectors, plus store support: an optional pinned flag that survives migration, a cached spawnedBy on tab info, setPinned, and closeMany (skips pinned tabs, records every closed tab for reopen). --- .../components/titlebar-tab-arrange.test.ts | 145 +++++++++++++++++ .../src/components/titlebar-tab-arrange.ts | 151 ++++++++++++++++++ .../app/src/context/tab-migration.test.ts | 34 ++++ .../packages/app/src/context/tab-migration.ts | 5 +- .../overlay/packages/app/src/context/tabs.tsx | 69 +++++++- 5 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-arrange.test.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-arrange.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/context/tab-migration.test.ts diff --git a/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-arrange.test.ts b/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-arrange.test.ts new file mode 100644 index 000000000..25411e702 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-arrange.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from "bun:test" +import { + arrangeTabs, + closeFinishedKeys, + closeGroupKeys, + closeOtherKeys, + closeRightKeys, + filterTabList, + readSpawnedBy, + type ArrangedTab, +} from "./titlebar-tab-arrange" + +const keys = (arranged: ArrangedTab[]) => arranged.map((tab) => tab.key) + +describe("arrangeTabs", () => { + test("keeps the stored order when nothing is pinned or spawned", () => { + const arranged = arrangeTabs([{ key: "a" }, { key: "b" }, { key: "c" }]) + expect(keys(arranged)).toEqual(["a", "b", "c"]) + expect(arranged.every((tab) => tab.depth === 0 && tab.group === undefined)).toBe(true) + }) + + test("pinned tabs come first, in their stored order", () => { + const arranged = arrangeTabs([{ key: "a" }, { key: "p1", pinned: true }, { key: "b" }, { key: "p2", pinned: true }]) + expect(keys(arranged)).toEqual(["p1", "p2", "a", "b"]) + expect(arranged.slice(0, 2).every((tab) => tab.pinned)).toBe(true) + }) + + test("a spawned tab sits right after its parent, siblings in stored order", () => { + const arranged = arrangeTabs([ + { key: "c1", parentKey: "p" }, + { key: "x" }, + { key: "p" }, + { key: "y" }, + { key: "c2", parentKey: "p" }, + ]) + expect(keys(arranged)).toEqual(["x", "p", "c1", "c2", "y"]) + const group = arranged.filter((tab) => tab.group === "p") + expect(group.map((tab) => [tab.key, tab.depth, tab.groupEdge])).toEqual([ + ["p", 0, "start"], + ["c1", 1, "middle"], + ["c2", 1, "end"], + ]) + }) + + test("grandchildren group under the top-most open ancestor", () => { + const arranged = arrangeTabs([{ key: "g", parentKey: "c" }, { key: "p" }, { key: "c", parentKey: "p" }]) + expect(keys(arranged)).toEqual(["p", "g", "c"]) + expect(arranged.every((tab) => tab.group === "p")).toBe(true) + }) + + test("a child whose parent tab is not open stands alone", () => { + const arranged = arrangeTabs([{ key: "a" }, { key: "c", parentKey: "gone" }]) + expect(keys(arranged)).toEqual(["a", "c"]) + expect([arranged[1].depth, arranged[1].group]).toEqual([0, undefined]) + }) + + test("pinning beats grouping: a pinned child or a pinned parent breaks the group", () => { + const pinnedChild = arrangeTabs([{ key: "p" }, { key: "c", parentKey: "p", pinned: true }]) + expect(keys(pinnedChild)).toEqual(["c", "p"]) + expect(pinnedChild.every((tab) => tab.group === undefined)).toBe(true) + + const pinnedParent = arrangeTabs([{ key: "c", parentKey: "p" }, { key: "p", pinned: true }]) + expect(keys(pinnedParent)).toEqual(["p", "c"]) + expect([pinnedParent[1].depth, pinnedParent[1].group]).toEqual([0, undefined]) + }) + + test("a parent cycle does not loop", () => { + const arranged = arrangeTabs([ + { key: "a", parentKey: "b" }, + { key: "b", parentKey: "a" }, + ]) + expect(keys(arranged).sort()).toEqual(["a", "b"]) + }) +}) + +describe("bulk-close selectors", () => { + const arranged = arrangeTabs([ + { key: "pin", pinned: true }, + { key: "a" }, + { key: "p" }, + { key: "c", parentKey: "p" }, + { key: "draft" }, + { key: "z" }, + ]) + + test("close others keeps the anchor and every pinned tab", () => { + expect(closeOtherKeys(arranged, "p")).toEqual(["a", "c", "draft", "z"]) + }) + + test("close to the right follows the displayed order and skips pinned tabs", () => { + expect(closeRightKeys(arranged, "p")).toEqual(["c", "draft", "z"]) + expect(closeRightKeys(arranged, "pin")).toEqual(["a", "p", "c", "draft", "z"]) + expect(closeRightKeys(arranged, "z")).toEqual([]) + }) + + test("close finished only takes seen, idle session tabs that are not active or pinned", () => { + const status = { pin: "idle", a: "idle", p: "running", c: "attention", z: "idle" } as const + expect(closeFinishedKeys(arranged, (key) => status[key as keyof typeof status], "z")).toEqual(["a"]) + }) + + test("close finished leaves unread, errored and unknown-status tabs open", () => { + const status = { a: "done", p: "error", c: undefined, z: "idle" } as const + expect(closeFinishedKeys(arranged, (key) => status[key as keyof typeof status])).toEqual(["z"]) + }) + + test("close group returns the parent and its spawned children", () => { + expect(closeGroupKeys(arranged, "p")).toEqual(["p", "c"]) + expect(closeGroupKeys(arranged, "c")).toEqual(["p", "c"]) + expect(closeGroupKeys(arranged, "a")).toEqual([]) + }) +}) + +describe("filterTabList", () => { + const entries = [ + { key: "1", title: "Passaggissimo.jl #43 handoffs", project: "Passaggissimo.jl" }, + { key: "2", title: "Reading Aaron's Slack papers", project: "atom-transport-demo" }, + { key: "3", title: "Atoms Slack follow-up", project: "atom-transport-demo" }, + ] + + test("an empty query returns everything", () => { + expect(filterTabList(entries, " ").map((e) => e.key)).toEqual(["1", "2", "3"]) + }) + + test("every word must match the title or the project, case-insensitively", () => { + expect(filterTabList(entries, "slack").map((e) => e.key)).toEqual(["2", "3"]) + expect(filterTabList(entries, "SLACK atoms").map((e) => e.key)).toEqual(["3"]) + expect(filterTabList(entries, "passaggissimo handoffs").map((e) => e.key)).toEqual(["1"]) + expect(filterTabList(entries, "transport aaron").map((e) => e.key)).toEqual(["2"]) + expect(filterTabList(entries, "nothing")).toEqual([]) + }) +}) + +describe("readSpawnedBy", () => { + test("reads the session-spawn stamp", () => { + expect(readSpawnedBy({ spawned_by: "ses_parent", spawned_depth: 1 })).toBe("ses_parent") + }) + + test("anything else is not spawned", () => { + expect(readSpawnedBy(undefined)).toBeUndefined() + expect(readSpawnedBy(null)).toBeUndefined() + expect(readSpawnedBy({})).toBeUndefined() + expect(readSpawnedBy({ spawned_by: 42 })).toBeUndefined() + expect(readSpawnedBy({ spawned_by: "" })).toBeUndefined() + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-arrange.ts b/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-arrange.ts new file mode 100644 index 000000000..7abc9afb6 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-arrange.ts @@ -0,0 +1,151 @@ +// amicode(#2154): the session tab strip's DISPLAY order and its bulk-close +// selectors, kept pure so they are tested without a DOM. +// +// The persisted tab list stays the source of truth for order; the strip only +// derives how to show it: +// - pinned tabs first (stored order among themselves); +// - then every unpinned tab, with tabs spawned by another open tab pulled +// in right after that tab (their "group"), siblings in stored order. +// Dragging rewrites the stored order and this arrangement is re-derived, so a +// dragged parent carries its group and a pinned/unpinned crossing snaps back. + +import type { SessionTabStatus } from "@/pages/layout/session-tab-status" + +export type ArrangeItem = { + key: string + pinned?: boolean + /** tab key of the tab this one was spawned from, when that tab is open */ + parentKey?: string +} + +export type ArrangedTab = { + key: string + pinned: boolean + /** 0 = a top-level tab, 1 = spawned from the group's root */ + depth: 0 | 1 + /** root tab key of the spawned-session group, when the tab is in one */ + group?: string + groupEdge?: "start" | "middle" | "end" +} + +export function arrangeTabs(items: ArrangeItem[]): ArrangedTab[] { + const byKey = new Map(items.map((item) => [item.key, item])) + + // Walk up through open, unpinned ancestors. A pinned tab never takes part in + // a group — pinning is the stronger organisation. + const rootOf = (item: ArrangeItem) => { + if (item.pinned) return item.key + let current = item + const seen = new Set([item.key]) + while (current.parentKey) { + const parent = byKey.get(current.parentKey) + if (!parent || parent.pinned || seen.has(parent.key)) break + seen.add(parent.key) + current = parent + } + return current.key + } + + const pinned: ArrangedTab[] = [] + const roots: ArrangeItem[] = [] + const children = new Map() + for (const item of items) { + if (item.pinned) { + pinned.push({ key: item.key, pinned: true, depth: 0 }) + continue + } + const root = rootOf(item) + if (root === item.key) { + roots.push(item) + continue + } + const list = children.get(root) + if (list) list.push(item) + else children.set(root, [item]) + } + + // A cycle leaves no root for its members — surface them as top-level tabs + // rather than dropping them. + const placed = new Set([...pinned.map((tab) => tab.key), ...roots.map((item) => item.key)]) + for (const [root, list] of children) { + if (placed.has(root)) continue + children.delete(root) + roots.push(...list) + } + + const out = [...pinned] + for (const root of roots) { + const kids = children.get(root.key) ?? [] + if (kids.length === 0) { + out.push({ key: root.key, pinned: false, depth: 0 }) + continue + } + out.push({ key: root.key, pinned: false, depth: 0, group: root.key, groupEdge: "start" }) + kids.forEach((kid, index) => + out.push({ + key: kid.key, + pinned: false, + depth: 1, + group: root.key, + groupEdge: index === kids.length - 1 ? "end" : "middle", + }), + ) + } + return out +} + +/** Every unpinned tab except the anchor. */ +export function closeOtherKeys(arranged: ArrangedTab[], anchor: string) { + return arranged.filter((tab) => !tab.pinned && tab.key !== anchor).map((tab) => tab.key) +} + +/** Unpinned tabs displayed after the anchor. */ +export function closeRightKeys(arranged: ArrangedTab[], anchor: string) { + const index = arranged.findIndex((tab) => tab.key === anchor) + if (index === -1) return [] + return arranged + .slice(index + 1) + .filter((tab) => !tab.pinned) + .map((tab) => tab.key) +} + +/** Unpinned tabs whose session is done and already seen ("idle"); never the + * active tab, never one that is working, waiting on you, errored, unread, or + * whose status is not known yet. */ +export function closeFinishedKeys( + arranged: ArrangedTab[], + statusOf: (key: string) => SessionTabStatus | undefined, + active?: string, +) { + return arranged + .filter((tab) => !tab.pinned && tab.key !== active && statusOf(tab.key) === "idle") + .map((tab) => tab.key) +} + +/** The spawned-session group the tab belongs to (root first), or nothing. */ +export function closeGroupKeys(arranged: ArrangedTab[], key: string) { + const group = arranged.find((tab) => tab.key === key)?.group + if (!group) return [] + return arranged.filter((tab) => tab.group === group).map((tab) => tab.key) +} + +export type TabListEntry = { key: string; title: string; project?: string } + +/** The overflow list's search: every whitespace-separated word must appear in + * the title or the project name, case-insensitively. */ +export function filterTabList(entries: T[], query: string): T[] { + const words = query.toLowerCase().split(/\s+/).filter(Boolean) + if (words.length === 0) return entries + return entries.filter((entry) => { + const haystack = `${entry.title} ${entry.project ?? ""}`.toLowerCase() + return words.every((word) => haystack.includes(word)) + }) +} + +/** The session-spawn stamp (`metadata.spawned_by`) the amicode_session tool + * writes on every child it creates. */ +export function readSpawnedBy(metadata: unknown): string | undefined { + if (!metadata || typeof metadata !== "object") return undefined + const value = (metadata as { spawned_by?: unknown }).spawned_by + return typeof value === "string" && value.length > 0 ? value : undefined +} diff --git a/packages/app-bundle/overlay/packages/app/src/context/tab-migration.test.ts b/packages/app-bundle/overlay/packages/app/src/context/tab-migration.test.ts new file mode 100644 index 000000000..c71778e82 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/tab-migration.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test" +import { migrateTabs } from "./tab-migration" +import type { ServerConnection } from "./server" + +const server = "http://127.0.0.1:4096" as ServerConnection.Key + +describe("tab migration — pinned tabs (amicode#2154)", () => { + test("keeps the pinned flag on session tabs", () => { + expect(migrateTabs([{ type: "session", server, sessionId: "a", pinned: true }], server)).toEqual([ + { type: "session", server, sessionId: "a", pinned: true }, + ]) + }) + + test("drops a non-true pinned value instead of storing it", () => { + expect( + migrateTabs( + [ + { type: "session", server, sessionId: "a", pinned: false }, + { type: "session", server, sessionId: "b", pinned: "yes" }, + ], + server, + ), + ).toEqual([ + { type: "session", server, sessionId: "a" }, + { type: "session", server, sessionId: "b" }, + ]) + }) + + test("never pins a draft", () => { + expect( + migrateTabs([{ type: "draft", server, draftID: "d", directory: "/repo", pinned: true }], server), + ).toEqual([{ type: "draft", server, draftID: "d", directory: "/repo", worktree: undefined }]) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/context/tab-migration.ts b/packages/app-bundle/overlay/packages/app/src/context/tab-migration.ts index 834a4fe18..29ebc2274 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/tab-migration.ts +++ b/packages/app-bundle/overlay/packages/app/src/context/tab-migration.ts @@ -21,7 +21,10 @@ export function migrateTabs( server = fallback } if (tab.type === "session" && typeof tab.sessionId === "string") { - return [{ type: tab.type, server, sessionId: tab.sessionId }] + // amicode(#2154): a pinned tab stays pinned across reloads; any other + // value of the flag is dropped so the stored shape stays minimal. + const pinned = "pinned" in tab && tab.pinned === true + return [pinned ? { type: tab.type, server, sessionId: tab.sessionId, pinned } : { type: tab.type, server, sessionId: tab.sessionId }] } if ( tab.type === "draft" && diff --git a/packages/app-bundle/overlay/packages/app/src/context/tabs.tsx b/packages/app-bundle/overlay/packages/app/src/context/tabs.tsx index 3df6dc120..4194b4d92 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/tabs.tsx +++ b/packages/app-bundle/overlay/packages/app/src/context/tabs.tsx @@ -21,6 +21,9 @@ export type SessionTab = { type: "session" server: ServerConnection.Key sessionId: string + /** amicode(#2154): pinned tabs render compact at the left of the strip and + * are skipped by every bulk close. Never part of the tab's identity. */ + pinned?: boolean } export type DraftTab = { @@ -36,6 +39,10 @@ export type Tab = SessionTab | DraftTab export type TabInfo = { title?: string directory?: string + /** amicode(#2154): the session this tab's session was spawned from + * (`metadata.spawned_by`), or null once checked and found unstamped. + * undefined = not checked yet. Cached so grouping holds on cold restores. */ + spawnedBy?: string | null } type RecentTab = { @@ -464,11 +471,71 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }, rememberSessionInfo(tab: SessionTab, session: Session) { const key = tabKey(tab) - const next = { title: session.title, directory: session.directory } const current = info[key] + const next = { ...current, title: session.title, directory: session.directory } if (current?.title === next.title && current.directory === next.directory) return setInfo(key, next) }, + /** amicode(#2154): cache the tab's spawn parent (see TabInfo.spawnedBy). */ + rememberSpawnedBy(tab: SessionTab, spawnedBy: string | null) { + const key = tabKey(tab) + if (info[key]?.spawnedBy === spawnedBy) return + setInfo(key, { ...info[key], spawnedBy }) + }, + /** amicode(#2154): pin or unpin a session tab in place. */ + setPinned(tab: SessionTab, pinned: boolean) { + const key = tabKey(tab) + const index = store.findIndex((item) => tabKey(item) === key) + if (index === -1) return + setStore( + index, + produce((item) => { + if (item.type !== "session") return + if (pinned) item.pinned = true + else delete item.pinned + }), + ) + }, + /** amicode(#2154): close a set of tabs in one step — the context menu's + * bulk closes. Pinned tabs are never closed this way. Every closed + * session tab goes onto the closed stack (reopen-closed-tab brings them + * back one by one). When the active tab is among them, `anchor` becomes + * active (or the first tab left, or home). `activeKey` lets the strip + * name the active tab directly (a subagent route maps onto its parent's + * tab, which the route alone does not reveal). */ + closeMany(keys: string[], opts?: { anchor?: Tab; activeKey?: string }) { + const targets = new Set(keys) + const removed = store.flatMap((tab, index) => + targets.has(tabKey(tab)) && !(tab.type === "session" && tab.pinned) ? [{ tab, index }] : [], + ) + if (removed.length === 0) return + const removedKeys = new Set(removed.map(({ tab }) => tabKey(tab))) + const routeActive = store[currentTabIndex()] + const activeKey = opts?.activeKey ?? (routeActive ? tabKey(routeActive) : undefined) + const removedActive = !!activeKey && removedKeys.has(activeKey) + const remaining = store.filter((tab) => !removedKeys.has(tabKey(tab))) + const anchor = opts?.anchor + const next = removedActive + ? (anchor && remaining.find((tab) => tabKey(tab) === tabKey(anchor))) ?? remaining[0] + : undefined + updateClosed((stack) => removed.reduce((acc, { tab, index }) => pushClosedTab(acc, tab, index), stack)) + void startTransition(() => { + setStore(() => remaining) + if (!removedActive) return + if (next) navigateTab(next) + else { + setRecentKey(undefined) + navigate("/") + } + }) + for (const { tab } of removed) { + const key = tabKey(tab) + memory.remove(key) + removeInfo(key) + if (tab.type === "draft") removeDraftPersisted(tab.draftID) + } + if (recent.key && removedKeys.has(recent.key) && !removedActive) setRecentKey(undefined) + }, select: navigateTab, remember(tab: Tab) { const key = tabKey(tab) From 9951219c1ebc076df083ae38be870e23394dda04 Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Sun, 8 Nov 2026 10:28:55 -0500 Subject: [PATCH 2/3] feat(app): readable, organisable session tabs (#2154) - tabs keep a 160px floor (the active tab its full 224px); past that the strip scrolls (vertical wheel too) instead of squeezing to a dot - truncated titles end in an ellipsis; the active tab is always scrolled into view, however it became active - an all-tabs list (search, status, project) from a chevron once tabs overflow, and from mod+alt+a / the palette - a tab context menu: rename, pin, close, close others / to the right / finished / group; fixes the selector that opened Customize on tabs - pinned tabs: compact status-dot tabs at the left, no close affordance - spawned sessions (metadata.spawned_by) shown after their parent with a shared underline - hover preview after 400ms instead of 2s; tabs expose their full title --- .../app/src/components/titlebar-tab-list.tsx | 236 ++++++++ .../app/src/components/titlebar-tab-menu.tsx | 63 +++ .../app/src/components/titlebar-tab-nav.css | 78 ++- .../app/src/components/titlebar-tab-nav.tsx | 153 +++-- .../src/components/titlebar-tab-popover.tsx | 102 ++++ .../app/src/components/titlebar-tab-strip.tsx | 534 +++++++++++++----- .../packages/app/src/components/titlebar.tsx | 6 +- 7 files changed, 978 insertions(+), 194 deletions(-) create mode 100644 packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-list.tsx create mode 100644 packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-menu.tsx create mode 100644 packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-popover.tsx diff --git a/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-list.tsx b/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-list.tsx new file mode 100644 index 000000000..d5d591c2f --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-list.tsx @@ -0,0 +1,236 @@ +// amicode(#2154): the "all tabs" list — a searchable list of every open tab, +// shown from a chevron at the end of the strip once tabs no longer fit, and +// from the tab.list command (palette + keybind). Same shape as the model +// selector: a MenuV2 with a search field that keeps focus and drives the +// active row with the arrow keys. +import { createEffect, createMemo, createSignal, For, Show } from "solid-js" +import { createEventListener } from "@solid-primitives/event-listener" +import { handleDocumentSearchKeydown } from "@/utils/search-keydown" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { SessionTabStatusDot, type SessionTabStatus } from "@/pages/layout/session-tab-status" +import { filterTabList } from "./titlebar-tab-arrange" + +export type TabListRow = { + key: string + title: string + project?: string + status?: SessionTabStatus + pinned: boolean + depth: 0 | 1 + active: boolean + draft: boolean +} + +export function TabListMenu(props: { + rows: () => TabListRow[] + open: boolean + onOpenChange: (open: boolean) => void + onSelect: (key: string) => void + /** shown in the trigger's tooltip */ + keybind?: string +}) { + const [search, setSearch] = createSignal("") + const [activeKey, setActiveKey] = createSignal() + let searchRef: HTMLInputElement | undefined + let listRef: HTMLDivElement | undefined + + const filtered = createMemo(() => filterTabList(props.rows(), search())) + + // Opening resets the query and puts the cursor on the current tab. + createEffect(() => { + if (!props.open) return + setSearch("") + setActiveKey(props.rows().find((row) => row.active)?.key ?? props.rows()[0]?.key) + // After the menu's own open focus has run (same timing as the model + // selector), move focus into the search field. + setTimeout(() => + requestAnimationFrame(() => { + searchRef?.focus() + scrollActiveIntoView() + }), + ) + }) + + // While open, the list owns the keyboard wherever focus happens to sit: + // typing goes to the search, arrows move the cursor, Enter opens the tab. + createEffect(() => { + if (!props.open) return + createEventListener( + document, + "keydown", + (event: KeyboardEvent) => { + if (event.target === searchRef) return + if (handleNavigationKey(event)) return + handleDocumentSearchKeydown(searchRef, event, search(), setSearch) + }, + true, + ) + }) + + /** Arrow / Enter / Escape for the list; true when the key was handled. */ + const handleNavigationKey = (event: KeyboardEvent) => { + if (event.altKey || event.metaKey || event.ctrlKey || event.isComposing) return false + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault() + event.stopPropagation() + move(event.key === "ArrowDown" ? 1 : -1) + return true + } + if (event.key === "Enter") { + event.preventDefault() + event.stopPropagation() + select(activeKey()) + return true + } + if (event.key === "Escape") { + event.preventDefault() + event.stopPropagation() + props.onOpenChange(false) + return true + } + return false + } + + // Keep the cursor on a row that is still listed. + createEffect(() => { + const rows = filtered() + if (rows.some((row) => row.key === activeKey())) return + setActiveKey(rows[0]?.key) + }) + + const scrollActiveIntoView = () => { + const key = activeKey() + if (!key || !listRef) return + for (const el of listRef.querySelectorAll("[data-tab-list-key]")) { + if (el.dataset.tabListKey === key) el.scrollIntoView({ block: "nearest" }) + } + } + + const move = (offset: 1 | -1) => { + const rows = filtered() + if (rows.length === 0) return + const index = rows.findIndex((row) => row.key === activeKey()) + const next = rows[(index + offset + rows.length) % rows.length] + setActiveKey(next.key) + requestAnimationFrame(scrollActiveIntoView) + } + + const select = (key: string | undefined) => { + if (!key) return + props.onOpenChange(false) + props.onSelect(key) + } + + return ( + + + } + aria-label="All tabs" + /> + + + +
+ +
+
+
(listRef = el)} class="flex max-h-[min(420px,60vh)] min-h-0 flex-col overflow-y-auto p-0.5"> + 0} + fallback={ +
+ No open tab matches “{search().trim()}” +
+ } + > + + {(row) => ( + { + setActiveKey(row.key) + setTimeout(() => searchRef?.focus()) + }} + onSelect={() => select(row.key)} + > + + + + + } + > + +
+ + + {row.title} + + + {row.project} + + + + Pinned + + + (current tab) + + + + )} + + +
+ + + + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-menu.tsx b/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-menu.tsx new file mode 100644 index 000000000..10ac3eee9 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-menu.tsx @@ -0,0 +1,63 @@ +// amicode(#2154): the session tab right-click menu, shared by session and +// draft tabs. The strip computes the model (what each bulk close would close) +// lazily — the menu content only renders while the menu is open. +import { Show } from "solid-js" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { useLanguage } from "@/context/language" + +export type TabMenu = { + /** present only for tabs that can be pinned (session tabs) */ + pinned?: boolean + onTogglePin?: () => void + /** how many tabs each bulk close would close; 0 disables the item */ + closeOthers: number + closeRight: number + closeFinished: number + /** size of the spawned-session group the tab is in; 0 hides the item */ + group: number + onCloseOthers: () => void + onCloseRight: () => void + onCloseFinished: () => void + onCloseGroup: () => void +} + +const counted = (label: string, count: number) => (count > 0 ? `${label} (${count})` : label) + +export function TabMenuItems(props: { + menu: TabMenu + onRename?: () => void + renameDisabled?: boolean + onClose: () => void +}) { + const language = useLanguage() + return ( + <> + + {(rename) => ( + + {language.t("common.rename")} + + )} + + + {(toggle) => ( + {props.menu.pinned ? "Unpin tab" : "Pin tab"} + )} + + + {language.t("common.closeTab")} + + {counted("Close other tabs", props.menu.closeOthers)} + + + {counted("Close tabs to the right", props.menu.closeRight)} + + + {counted("Close finished tabs", props.menu.closeFinished)} + + 0}> + {counted("Close group", props.menu.group)} + + + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-nav.css b/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-nav.css index a89d8a989..fff38916b 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-nav.css +++ b/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-nav.css @@ -68,32 +68,12 @@ display: none; } -[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] { - --tab-title-fade-offset: 4px; - -webkit-mask-image: linear-gradient( - to right, - black 0, - black calc(100% - var(--tab-title-fade-offset) - 16px), - transparent calc(100% - var(--tab-title-fade-offset)), - transparent 100% - ); - mask-image: linear-gradient( - to right, - black 0, - black calc(100% - var(--tab-title-fade-offset) - 16px), - transparent calc(100% - var(--tab-title-fade-offset)), - transparent 100% - ); -} - -[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"]) - [data-slot="tab-link"] { - --tab-title-fade-offset: 24px; -} - -[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"]) - [data-slot="tab-link"] { - padding-right: 0; +/* amicode(#2154): truncated titles end in an ellipsis (text-ellipsis on the + title) instead of fading out, so a cut-off title reads as cut off. The + active tab always shows its close button, so its title stops short of it; + on hover the close button's own background covers the title's tail. */ +[data-titlebar-tab][data-active="true"]:not([data-pinned]):not([data-editing="true"]) [data-slot="tab-link"] { + padding-right: 20px; } [data-titlebar-tab] [data-slot="tab-title"] { @@ -157,3 +137,49 @@ animation: none; } } + +/* amicode(#2154): spawned-session groups. A parent tab and the tabs spawned + from it share a 2px underline (neutral strong edge — yellow can't define on + light). The bar bridges the 6px gap between slots so the group reads as one. */ +[data-titlebar-tab-slot][data-group-edge]::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2px; + border-radius: var(--radius-full); + background: var(--v2-border-border-strong); + pointer-events: none; +} + +[data-titlebar-tab-slot][data-group-edge="start"]::after { + left: 6px; + right: -6px; +} + +[data-titlebar-tab-slot][data-group-edge="middle"]::after { + right: -6px; +} + +[data-titlebar-tab-slot][data-group-edge="end"]::after { + right: 6px; +} + +/* The bar already separates grouped tabs; no divider tick between them. */ +[data-titlebar-tab-slot][data-group-edge="middle"]::before, +[data-titlebar-tab-slot][data-group-edge="end"]::before { + display: none; +} + +/* amicode(#2154): a pinned tab is its status dot — the title lives in the + hover preview and the all-tabs list. */ +[data-titlebar-tab][data-pinned] [data-titlebar-tab-title] { + display: none; +} + +[data-titlebar-tab][data-pinned] [data-titlebar-tab-link] { + justify-content: center; + gap: 0; + padding-inline: 0; +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-nav.tsx index b256fd775..9648ac08d 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-nav.tsx @@ -2,6 +2,7 @@ import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } fro import { makeEventListener } from "@solid-primitives/event-listener" import { createResizeObserver } from "@solid-primitives/resize-observer" import { createMutation } from "@tanstack/solid-query" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { useGlobal } from "@/context/global" @@ -11,6 +12,7 @@ import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" import type { Session } from "@opencode-ai/sdk/v2" import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture" import { TabPreviewPopover } from "./titlebar-tab-popover" +import { TabMenuItems, type TabMenu } from "./titlebar-tab-menu" import "./titlebar-tab-nav.css" // MouseEvent.button uses 1 for the middle/wheel button. @@ -33,8 +35,16 @@ export function TabNavItem(props: { dragging?: boolean pressed?: boolean hidden?: boolean + /** amicode(#2154): compact, protected from accidental close */ + pinned?: boolean + /** amicode(#2154): the right-click menu's model (evaluated while open) */ + menu?: () => TabMenu + /** amicode(#2154): extra hover-preview line, e.g. "Spawned from …" */ + note?: string }) { const [editing, setEditing] = createSignal(false) + const [menuOpen, setMenuOpen] = createSignal(false) + let renameFromMenu = false const [titleOverflowing, setTitleOverflowing] = createSignal(false) let tabRoot!: HTMLDivElement let titleEl!: HTMLSpanElement @@ -77,7 +87,7 @@ export function TabNavItem(props: { }) const [popoverOpen, setPopoverOpen] = createSignal(false) - const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session() + const previewBlocked = () => !!props.dragging || editing() || menuOpen() || !!props.pressed || !props.session() const measureTitleOverflow = () => { if (!titleEl || editing()) { @@ -139,9 +149,9 @@ export function TabNavItem(props: { titleEl.textContent = value }) - const openRename = (event: MouseEvent) => { - event.preventDefault() - event.stopPropagation() + const openRename = (event?: MouseEvent) => { + event?.preventDefault() + event?.stopPropagation() if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return const session = props.session() if (!session) return @@ -172,7 +182,7 @@ export function TabNavItem(props: { onCleanup(cleanup) }) - const tab = ( + const tab = () => (
{ tabRoot = el @@ -182,6 +192,7 @@ export function TabNavItem(props: { data-slot="titlebar-tab-item" data-title-overflow={titleOverflowing()} data-editing={editing()} + data-pinned={props.pinned || 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-sm px-1.5 [container-type:inline-size]" classList={{ invisible: props.hidden }} data-active={props.active} @@ -194,10 +205,21 @@ export function TabNavItem(props: { }} onAuxClick={(event) => { if (event.button !== MIDDLE_MOUSE_BUTTON) return + // A pinned tab ignores middle-click — it is pinned so a stray click + // can't take it away. Close stays in its menu and on mod+w. + if (props.pinned) { + event.preventDefault() + return + } closeTab(event) }} > - - + -
- { - event.preventDefault() - event.stopPropagation() - }} - onClick={closeTab} - icon={} - /> -
+ +
+ { + event.preventDefault() + event.stopPropagation() + }} + onClick={closeTab} + icon={} + aria-label={title() ? `Close ${title()}` : "Close tab"} + /> +
+
) return ( - { - if (value && previewBlocked()) return - setPopoverOpen(value) - }} - data={{ - projectName: projectName(), - title: props.session()?.title, - path: previewPath(), - serverName: serverLabel(), + { + setMenuOpen(open) + if (open) setPopoverOpen(false) }} - /> + > + { + if (value && previewBlocked()) return + setPopoverOpen(value) + }} + data={{ + projectName: projectName(), + title: props.session()?.title, + path: previewPath(), + note: props.note, + serverName: serverLabel(), + }} + /> + + {(menu) => ( + + { + // Rename from the menu: wait for the menu to hand focus back, + // then open the inline editor (it needs focus to stay). + if (!renameFromMenu) return + event.preventDefault() + renameFromMenu = false + openRename() + }} + > + { + renameFromMenu = true + }} + onClose={props.onClose} + /> + + + )} + + ) } @@ -346,13 +404,14 @@ export function DraftTabItem(props: { dragging?: boolean pressed?: boolean hidden?: boolean + menu?: () => TabMenu }) { const closeTab = (event: MouseEvent) => { event.preventDefault() event.stopPropagation() props.onClose() } - return ( + const tab = () => (
forwardTabRef(props.ref, el)} data-titlebar-tab @@ -372,7 +431,11 @@ export function DraftTabItem(props: { closeTab(event) }} > - {props.title} - +
) + return ( + + {tab()} + + {(menu) => ( + + + + + + )} + + + ) } diff --git a/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-popover.tsx b/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-popover.tsx new file mode 100644 index 000000000..6680d6066 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-popover.tsx @@ -0,0 +1,102 @@ +import { HoverCard as Kobalte } from "@kobalte/core/hover-card" +import { createSignal, Show, type JSXElement } from "solid-js" +import "./titlebar-tab-popover.css" + +// Initial hover delay before the preview appears. amicode(#2154): 400ms, not +// upstream's 2s — with many tabs open the preview is how a squeezed title is +// read, so it has to arrive while the pointer is still there. +export const OPEN_DELAY = 400 +// Mouse-out delay: begin closing immediately (a brief exit animation plays). +const CLOSE_DELAY = 0 +// After a preview closes, hovering a neighbouring tab within this window skips +// the open delay — mirrors the tooltip's skipDelayDuration so moving across +// tabs doesn't re-wait the full delay each time. +const SKIP_WINDOW = 500 +let lastClosedAt = 0 + +export interface TabPreviewData { + projectName?: string + title?: string + path?: string + serverName?: string + /** amicode(#2154): one extra muted line, e.g. "Spawned from …" */ + note?: string +} + +export function TabPreviewPopover(props: { + trigger: JSXElement + open: boolean + onOpenChange: (open: boolean) => void + data: TabPreviewData +}) { + let triggerEl: HTMLDivElement | undefined + // When opened during a rapid tab-hopping streak, this preview appears and + // disappears instantly (no repeated enter/exit animation) — only the first, + // "cold" preview animates. Mirrors how browsers reuse one tab tooltip. + const [instant, setInstant] = createSignal(false) + + const warm = () => Date.now() - lastClosedAt < SKIP_WINDOW + // Kobalte reads openDelay lazily when the pointer enters the trigger, so this + // resolves the skip window per-hover. + const resolveOpenDelay = () => (warm() ? 0 : OPEN_DELAY) + const handleOpenChange = (open: boolean) => { + if (open) setInstant(warm()) + else lastClosedAt = Date.now() + props.onOpenChange(open) + } + + return ( + + + {props.trigger} + + + { + // Portalled content lives outside the themed subtree, so mirror the + // active theme like the v2 tooltip does. + const theme = triggerEl?.closest("[data-theme]")?.getAttribute("data-theme") + if (theme) el.setAttribute("data-theme", theme) + }} + data-component="session-tab-popover" + data-instant={instant() || undefined} + > +
+ + {props.data.projectName} + + + {props.data.title} + +
+ + +
+ {props.data.path} +
+
+ + +
+ {props.data.note} +
+
+ + +
{props.data.serverName}
+
+
+
+
+ ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-strip.tsx index 0d4da88c7..f6fa6e2b7 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-strip.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-strip.tsx @@ -1,6 +1,7 @@ -import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount, Show } from "solid-js" +import { createEffect, createMemo, createResource, createRoot, createSignal, For, on, onCleanup, onMount, Show } from "solid-js" import { createStore } from "solid-js/store" import { createResizeObserver } from "@solid-primitives/resize-observer" +import { makeEventListener } from "@solid-primitives/event-listener" import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" import { isSortable, useSortable } from "@dnd-kit/solid/sortable" import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom" @@ -16,21 +17,54 @@ import { useCommand } from "@/context/command" import { useTabs } from "@/context/tabs" import { createTabPromptState } from "@/context/prompt" import { base64Encode } from "@opencode-ai/core/util/encode" +import { getFilename } from "@opencode-ai/core/util/path" import { showToast } from "@/utils/toast" import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture" import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order" +import { + arrangeTabs, + closeFinishedKeys, + closeGroupKeys, + closeOtherKeys, + closeRightKeys, + readSpawnedBy, + type ArrangedTab, +} from "./titlebar-tab-arrange" +import type { TabMenu } from "./titlebar-tab-menu" +import { TabListMenu, type TabListRow } from "./titlebar-tab-list" import type { Session } from "@opencode-ai/sdk/v2" import { useControlProjectionForConnection } from "@/components/session/session-fleet-control-projection" import { drivingBannerFromProjection, drivenByBannerFromProjection } from "@/components/session/session-fleet-peers" +import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" +import { sessionTabStatus, type SessionTabStatus } from "@/pages/layout/session-tab-status" + +// amicode(#2154): a tab never squeezes below 160px (min-w-40, ~18 characters +// of title) — past that the strip scrolls instead and the all-tabs list takes +// over. The active tab keeps its full 224px so the title you are on is always +// legible. A pinned tab is a fixed 36px status-dot tab. +const SLOT_CLASS = "relative flex w-56 min-w-40 max-w-56 flex-shrink" +const ACTIVE_SLOT_CLASS = "relative flex w-56 max-w-56 shrink-0" +const PINNED_SLOT_CLASS = "relative flex w-9 shrink-0" +const slotClass = (input: { pinned?: boolean; active: boolean }) => + input.pinned ? PINNED_SLOT_CLASS : input.active ? ACTIVE_SLOT_CLASS : SLOT_CLASS + +// A spawn stamp written moments after the tab opened (fork → PATCH) may not +// be there on the first look; only cache "not spawned" for older sessions. +const SPAWN_STAMP_GRACE_MS = 120_000 + +const TAB_LIST_KEYBIND = "mod+alt+a" function SessionTabSlot(props: { tab: SessionTab id: string index: () => number active: () => boolean + arrangement: () => ArrangedTab | undefined forceTruncate: boolean session: () => Session | undefined fallbackTitle?: string + note?: string + menu: () => TabMenu drivingMachineId?: () => string | undefined drivenByMachine?: () => { machineId: string; machineName: string } | undefined onRename: (title: string) => Promise @@ -46,6 +80,7 @@ function SessionTabSlot(props: { }, }) let ref!: HTMLDivElement + const pinned = () => !!props.tab.pinned return (
{ @@ -63,6 +101,9 @@ function SessionTabSlot(props: { server={props.tab.server} session={props.session} fallbackTitle={props.fallbackTitle} + note={props.note} + pinned={pinned()} + menu={props.menu} drivingMachineId={props.drivingMachineId} drivenByMachine={props.drivenByMachine} onRename={props.onRename} @@ -76,16 +117,47 @@ function SessionTabSlot(props: { ) } +/** Reports a loaded session's status dot up to the strip (for "Close finished" + * and the all-tabs list). Mounted only once the session's directory is known. */ +function SessionStatusReporter(props: { + server: ServerConnection.Key + directory: string + sessionId: string + onStatus: (status: SessionTabStatus | undefined) => void +}) { + const state = useSessionTabAvatarState( + () => props.server, + () => props.directory, + () => props.sessionId, + ) + createEffect(() => + props.onStatus( + sessionTabStatus({ + loading: state.loading(), + needsAttention: state.needsAttention(), + hasError: state.hasError(), + unread: state.unread(), + }), + ), + ) + onCleanup(() => props.onStatus(undefined)) + return null +} + function SessionTabEntry(props: { tab: SessionTab id: string index: () => number active: () => boolean + arrangement: () => ArrangedTab | undefined forceTruncate: boolean serverCtx: () => ServerCtx | undefined + note?: string + menu: () => TabMenu drivingMachineId?: () => string | undefined drivenByMachine?: () => { machineId: string; machineName: string } | undefined onVisibleChange: (visible: boolean) => void + onStatusChange: (status: SessionTabStatus | undefined) => void onNavigate: (element: HTMLDivElement) => void onClose: () => void }) { @@ -105,6 +177,7 @@ function SessionTabEntry(props: { const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session()) const visible = createMemo(() => !!session() || loadedSession.loading || missingSession() || !!persisted()?.title) let prefetched = false + let spawnChecked = false const rename = async (title: string) => { const value = session() @@ -157,23 +230,63 @@ function SessionTabEntry(props: { }) }) + // amicode(#2154): which session spawned this one (the amicode_session + // stamp, `metadata.spawned_by`). The v2 session info the sync layer keeps + // carries no metadata, so ask the session endpoint once; the answer is + // cached on the tab's info so cold restores group without a fetch. + createEffect(() => { + const ctx = props.serverCtx() + const value = session() + if (!ctx || !value) return + const stamped = readSpawnedBy((value as { metadata?: unknown }).metadata) + if (stamped) { + tabs.rememberSpawnedBy(props.tab, stamped) + return + } + if (persisted()?.spawnedBy !== undefined || spawnChecked) return + spawnChecked = true + void ctx.sdk.client.session + .get({ sessionID: value.id }) + .then((result) => { + const spawnedBy = readSpawnedBy(result.data?.metadata) + if (spawnedBy) tabs.rememberSpawnedBy(props.tab, spawnedBy) + else if (Date.now() - value.time.created > SPAWN_STAMP_GRACE_MS) tabs.rememberSpawnedBy(props.tab, null) + }) + .catch(() => {}) + }) + return ( - - - + <> + + {(directory) => ( + + )} + + + + + ) } @@ -183,6 +296,7 @@ function DraftTabSlot(props: { index: () => number active: () => boolean title: string + menu: () => TabMenu onNavigate: (element: HTMLDivElement) => void onClose: () => void }) { @@ -202,7 +316,7 @@ function DraftTabSlot(props: { data-titlebar-tab-slot data-tab-key={props.id} data-active={props.active()} - class="relative flex w-56 min-w-7 max-w-56 flex-shrink" + class={slotClass({ active: props.active() })} > { @@ -210,6 +324,7 @@ function DraftTabSlot(props: { }} href={tabHref(props.tab)} title={props.title} + menu={props.menu} onNavigate={() => props.onNavigate(ref)} onClose={props.onClose} active={props.active()} @@ -231,6 +346,7 @@ export function TitlebarTabStrip(props: { const global = useGlobal() const language = useLanguage() const command = useCommand() + const tabs = useTabs() // Fleet driving indicator: acquire the shared control projection (same // ref-counted singleton the session header's composer scrim consumes — no @@ -243,8 +359,111 @@ export function TitlebarTabStrip(props: { let listRef!: HTMLDivElement let resizeFrame: number | undefined const [visibility, setVisibility] = createStore>({}) + const [statuses, setStatuses] = createStore>({}) + const [overflowing, setOverflowing] = createSignal(false) + const [listOpen, setListOpen] = createSignal(false) + + const tabByKey = createMemo(() => new Map(props.tabs.map((tab) => [tabKey(tab), tab]))) const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)])) - const visibleTabIds = () => visibleTabs().map(tabKey) + + // amicode(#2154): the spawn parent's tab key, when that tab is open. + const parentKeyOf = (tab: Tab) => { + if (tab.type !== "session") return undefined + const spawnedBy = tabs.info[tabKey(tab)]?.spawnedBy + if (!spawnedBy) return undefined + return tabKey({ type: "session", server: tab.server, sessionId: spawnedBy }) + } + + // Display order: pinned first, then spawned sessions after their parent. + const arranged = createMemo(() => + arrangeTabs( + visibleTabs().map((tab) => ({ + key: tabKey(tab), + pinned: tab.type === "session" && !!tab.pinned, + parentKey: parentKeyOf(tab), + })), + ), + ) + const arrangedKeys = createMemo(() => arranged().map((tab) => tab.key)) + const arrangementByKey = createMemo(() => new Map(arranged().map((tab, index) => [tab.key, { tab, index }]))) + // Rendered order: the arranged visible tabs, then the not-yet-visible ones + // (they render nothing until their session resolves). Store proxies keep + // their identity, so moves entries instead of re-creating them. + const renderTabs = createMemo(() => { + const byKey = tabByKey() + const shown = arrangedKeys() + .map((key) => byKey.get(key)) + .filter((tab): tab is Tab => !!tab) + const placed = new Set(arrangedKeys()) + return [...shown, ...props.tabs.filter((tab) => !placed.has(tabKey(tab)))] + }) + + const activeKey = () => { + const current = props.currentTab() + return current ? tabKey(current) : undefined + } + + const titleOf = (tab: Tab) => + tab.type === "draft" ? language.t("command.session.new") : (tabs.info[tabKey(tab)]?.title ?? language.t("session.tab.unknown")) + + const spawnNote = (tab: Tab) => { + if (tab.type !== "session") return undefined + const parentKey = parentKeyOf(tab) + if (!parentKey) return undefined + const parent = tabs.info[parentKey]?.title + return parent ? `Spawned from “${parent}”` : "Spawned from another session" + } + + // The right-click menu: what each bulk close would close, computed when + // the menu opens (the content only renders while open). + const menuFor = (tab: Tab) => (): TabMenu => { + const key = tabKey(tab) + const list = arranged() + const active = activeKey() + const others = closeOtherKeys(list, key) + const right = closeRightKeys(list, key) + const finished = closeFinishedKeys(list, (k) => statuses[k], active) + const group = closeGroupKeys(list, key) + const afterGroup = () => { + const keys = list.map((item) => item.key) + const first = keys.findIndex((k) => group.includes(k)) + const neighbour = keys.slice(first).find((k) => !group.includes(k)) ?? keys.slice(0, first).findLast(() => true) + return neighbour ? tabByKey().get(neighbour) : undefined + } + return { + pinned: tab.type === "session" ? !!tab.pinned : undefined, + onTogglePin: tab.type === "session" ? () => tabs.setPinned(tab, !tab.pinned) : undefined, + closeOthers: others.length, + closeRight: right.length, + closeFinished: finished.length, + group: group.length, + onCloseOthers: () => tabs.closeMany(others, { anchor: tab, activeKey: active }), + onCloseRight: () => tabs.closeMany(right, { anchor: tab, activeKey: active }), + onCloseFinished: () => tabs.closeMany(finished, { activeKey: active }), + onCloseGroup: () => tabs.closeMany(group, { anchor: afterGroup(), activeKey: active }), + } + } + + const listRows = (): TabListRow[] => { + const active = activeKey() + return arranged().flatMap((item) => { + const tab = tabByKey().get(item.key) + if (!tab) return [] + const directory = tab.type === "draft" ? tab.directory : tabs.info[item.key]?.directory + return [ + { + key: item.key, + title: titleOf(tab), + project: directory ? getFilename(directory) : undefined, + status: statuses[item.key], + pinned: item.pinned, + depth: item.depth, + active: item.key === active, + draft: tab.type === "draft", + }, + ] + }) + } command.register("titlebar-tab-cycle", () => [ { @@ -263,18 +482,31 @@ export function TitlebarTabStrip(props: { hidden: true, onSelect: () => selectAdjacentTab(1), }, + { + id: `tab.list`, + category: "tab", + title: "Show all tabs", + keybind: TAB_LIST_KEYBIND, + onSelect: () => setListOpen(true), + }, ]) function selectAdjacentTab(offset: -1 | 1) { - const current = props.currentTab() - const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset) - const next = props.tabs.find((tab) => tabKey(tab) === key) + const key = adjacentTabKey(arrangedKeys(), activeKey(), offset) + const next = key ? tabByKey().get(key) : undefined if (next) props.onNavigate(next) } + function slotFor(key: string): HTMLElement | undefined { + if (!listRef) return undefined + return [...listRef.querySelectorAll("[data-titlebar-tab-slot]")].find((el) => el.dataset.tabKey === key) + } + function refreshOverflow() { if (!scrollRef) return - props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth) + const next = scrollRef.scrollWidth > scrollRef.clientWidth + setOverflowing(next) + props.onOverflowChange(next) } createResizeObserver( @@ -290,6 +522,18 @@ export function TitlebarTabStrip(props: { onMount(() => { refreshOverflow() + // amicode(#2154): a vertical mouse wheel scrolls the strip sideways. + makeEventListener( + scrollRef, + "wheel", + (event: WheelEvent) => { + if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return + if (scrollRef.scrollWidth <= scrollRef.clientWidth) return + scrollRef.scrollLeft += event.deltaY + event.preventDefault() + }, + { passive: false }, + ) }) onCleanup(() => { @@ -298,91 +542,126 @@ export function TitlebarTabStrip(props: { createEffect(() => { props.tabs.length - visibleTabIds() + arrangedKeys() refreshOverflow() }) + // amicode(#2154): however the active tab changes — click, keybind, palette, + // Sessions dropdown, all-tabs list — bring it into view. + createEffect( + on([activeKey, arrangedKeys], ([key]) => { + if (!key) return + requestAnimationFrame(() => slotFor(key)?.scrollIntoView({ block: "nearest", inline: "nearest" })) + }), + ) + return ( -
-
- - !canStartTabDrag(event.pointerType) || - isTabCloseTarget(event.target) || - (event.target instanceof Element && !!event.target.closest('[contenteditable="true"]')), - }), - ]} - modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => listRef })]} - plugins={(defaults) => [ - ...defaults.filter((plugin) => plugin !== Accessibility), - AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }), - Feedback.configure({ dropAnimation: null }), - ]} - onDragStart={(event) => { - const source = event.operation.source - if (!source) return - const tab = props.tabs.find((item) => tabKey(item) === source.id.toString()) - if (!tab) return - const tabEl = source.element?.querySelector("[data-titlebar-tab]") - props.onNavigate(tab, tabEl ?? undefined) - }} - onDragEnd={(event) => { - const current = visibleTabIds() - const source = event.operation.source - if (event.canceled || !isSortable(source)) return - - const { initialIndex, index } = source - if (initialIndex !== index) { - props.onReorder( - mergeVisibleTabOrder( - props.tabs.map(tabKey), - current, - arrayMove(current, source.initialIndex, source.index), - ), - ) - } - }} +
+
+
-
- - {(tab) => { - const id = tabKey(tab) - let ref!: HTMLDivElement - const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id) - useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref)) - const serverCtx = createMemo(() => { - if (tab.type !== "session") return - const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server) - if (conn) return global.ensureServerCtx(conn) - }) - - if (tab.type === "session") { - const drivingMachineId = createMemo(() => { - const driving = drivingBannerFromProjection(controlProjection(), tab.sessionId) - return driving?.machineId - }) - const drivenByMachine = createMemo(() => { - const driven = drivenByBannerFromProjection(controlProjection(), tab.sessionId) - return driven ?? undefined + + !canStartTabDrag(event.pointerType) || + isTabCloseTarget(event.target) || + (event.target instanceof Element && !!event.target.closest('[contenteditable="true"]')), + }), + ]} + modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => listRef })]} + plugins={(defaults) => [ + ...defaults.filter((plugin) => plugin !== Accessibility), + AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }), + Feedback.configure({ dropAnimation: null }), + ]} + onDragStart={(event) => { + const source = event.operation.source + if (!source) return + const tab = tabByKey().get(source.id.toString()) + if (!tab) return + const tabEl = source.element?.querySelector("[data-titlebar-tab]") + props.onNavigate(tab, tabEl ?? undefined) + }} + onDragEnd={(event) => { + const current = arrangedKeys() + const source = event.operation.source + if (event.canceled || !isSortable(source)) return + + const { initialIndex, index } = source + if (initialIndex !== index) { + // The drop rewrites the stored order; the arrangement is then + // re-derived, so a dragged parent carries its spawned tabs and + // a drop across the pinned boundary settles back on its side. + props.onReorder( + mergeVisibleTabOrder( + props.tabs.map(tabKey), + current, + arrayMove(current, source.initialIndex, source.index), + ), + ) + } + }} + > +
+ + {(tab) => { + const id = tabKey(tab) + let ref!: HTMLDivElement + const arrangement = () => arrangementByKey().get(id) + const visibleIndex = () => arrangement()?.index ?? -1 + useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref)) + const serverCtx = createMemo(() => { + if (tab.type !== "session") return + const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server) + if (conn) return global.ensureServerCtx(conn) }) + + if (tab.type === "session") { + const drivingMachineId = createMemo(() => { + const driving = drivingBannerFromProjection(controlProjection(), tab.sessionId) + return driving?.machineId + }) + const drivenByMachine = createMemo(() => { + const driven = drivenByBannerFromProjection(controlProjection(), tab.sessionId) + return driven ?? undefined + }) + return ( + props.currentTab() === tab} + arrangement={() => arrangement()?.tab} + forceTruncate={props.forceTruncate} + serverCtx={serverCtx} + note={spawnNote(tab)} + menu={menuFor(tab)} + drivingMachineId={drivingMachineId} + drivenByMachine={drivenByMachine} + onVisibleChange={(visible) => setVisibility(id, visible)} + onStatusChange={(status) => setStatuses(id, status)} + onNavigate={(element) => { + ref = element + props.onNavigate(tab, element) + }} + onClose={() => props.onClose(tab)} + /> + ) + } + return ( - props.currentTab() === tab} - forceTruncate={props.forceTruncate} - serverCtx={serverCtx} - drivingMachineId={drivingMachineId} - drivenByMachine={drivenByMachine} - onVisibleChange={(visible) => setVisibility(id, visible)} + title={language.t("command.session.new")} + menu={menuFor(tab)} onNavigate={(element) => { ref = element props.onNavigate(tab, element) @@ -390,37 +669,36 @@ export function TitlebarTabStrip(props: { onClose={() => props.onClose(tab)} /> ) - } - - return ( - props.currentTab() === tab} - title={language.t("command.session.new")} - onNavigate={(element) => { - ref = element - props.onNavigate(tab, element) - }} - onClose={() => props.onClose(tab)} - /> - ) - }} - -
-
+ }} +
+
+ +
+