Skip to content

Commit c8d5295

Browse files
committed
feat(tui): group vertical tabs by project with nested children
1 parent 2ef92c7 commit c8d5295

7 files changed

Lines changed: 410 additions & 277 deletions

File tree

‎PATCH.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ Session tabs default to vertical in the TUI (`tabs.layout`) and desktop app (`ap
8585

8686
Expanded vertical tab titles word-wrap onto as many rows as they need instead of cropping, and the tab grows to fit; the compact rail and project line still crop.
8787

88+
## Vertical Tab Project Groups (TUI)
89+
90+
The expanded vertical rail groups tabs under project headers and nests child sessions under their parent tab; manual tab reordering is disabled while `tabs.layout` is vertical.
91+
8892
## Session List Preview (TUI) [NOT MIGRATED]
8993

9094
The session list (`<leader>l`) previews the highlighted session's latest messages on terminals 128 columns or wider.

‎packages/tui/src/component/session-tabs.tsx‎

Lines changed: 303 additions & 268 deletions
Large diffs are not rendered by default.

‎packages/tui/src/context/session-tabs-model.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,33 @@ export function moveSessionTab(tabs: SessionTab[], sessionID: string, index: num
9999
return next
100100
}
101101

102+
/** Keeps each group's tabs contiguous, ordering groups by their first tab and tabs by their stored order. */
103+
export function groupSessionTabs(tabs: SessionTab[], groupOf: (sessionID: string) => string | undefined) {
104+
return [...Map.groupBy(tabs, (tab) => groupOf(tab.sessionID) ?? "").values()].flat()
105+
}
106+
107+
export type SessionTabChild = { sessionID: string; prefix: string }
108+
109+
/** Descendants of a tab's root session in creation order, with tree-guide prefixes such as "│ └─ ". */
110+
export function sessionTabChildren(
111+
rootID: string,
112+
sessions: readonly { id: string; parentID?: string; created: number }[],
113+
) {
114+
const byParent = Map.groupBy(
115+
sessions.filter((session) => session.parentID).toSorted((a, b) => a.created - b.created),
116+
(session) => session.parentID,
117+
)
118+
const walk = (parentID: string, indent: string): SessionTabChild[] =>
119+
(byParent.get(parentID) ?? []).flatMap((child, index, siblings) => {
120+
const last = index === siblings.length - 1
121+
return [
122+
{ sessionID: child.id, prefix: indent + (last ? "└─ " : "├─ ") },
123+
...walk(child.id, indent + (last ? " " : "│ ")),
124+
]
125+
})
126+
return walk(rootID, "")
127+
}
128+
102129
export function cycleSessionTab(
103130
tabs: readonly SessionTab[],
104131
active: string | undefined,

‎packages/tui/src/context/session-tabs.tsx‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { anchorKey, type AnchorTarget } from "../routes/session/anchors"
1818
import {
1919
closeSessionTab,
2020
cycleSessionTab,
21+
groupSessionTabs,
2122
moveSessionTab,
2223
moveSessionTabHistory,
2324
NEW_SESSION_TAB_TITLE,
@@ -136,6 +137,11 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
136137
unread: {},
137138
})
138139
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
140+
const grouped = () => config.tabs.layout === "vertical"
141+
// The vertical rail renders project groups, so numbering, cycling, and selection follow that order.
142+
const ordered = createMemo(() =>
143+
grouped() ? groupSessionTabs(state().tabs, (id) => data.session.get(id)?.projectID) : state().tabs,
144+
)
139145
createSessionRetention({
140146
session: data.session,
141147
current: () =>
@@ -335,7 +341,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
335341
const previous = selected
336342
? moveSessionTabHistory(recordSessionTabHistory(history, target), closed.tabs, target, -1)
337343
: { history, sessionID: undefined }
338-
const next = previous.sessionID ?? closed.next
344+
const next = previous.sessionID ?? closeSessionTab(ordered(), target).next
339345
history = previous.history
340346
update((draft) => {
341347
draft.tabs = closeSessionTab(draft.tabs, target).tabs
@@ -352,7 +358,14 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
352358
return {
353359
enabled,
354360
tabs() {
355-
return state().tabs
361+
return ordered()
362+
},
363+
viewing() {
364+
return route.data.type === "session" ? route.data.sessionID : undefined
365+
},
366+
selectChild(sessionID: string) {
367+
if (!enabled()) return
368+
route.navigate({ type: "session", sessionID })
356369
},
357370
recentlyClosed() {
358371
return closedTabs()
@@ -445,7 +458,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
445458
route.navigate({ type: "session", sessionID: result.sessionID })
446459
},
447460
move(sessionID: string, index: number) {
448-
if (!enabled()) return
461+
if (!enabled() || grouped()) return
449462
const session = root(sessionID)
450463
if (moveSessionTab(state().tabs, session, index) === state().tabs) return
451464
update((draft) => {
@@ -454,19 +467,19 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
454467
},
455468
cycle(direction: 1 | -1) {
456469
if (!enabled()) return
457-
const tab = cycleSessionTab(state().tabs, current(), direction)
470+
const tab = cycleSessionTab(ordered(), current(), direction)
458471
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
459472
},
460473
cycleUnread(direction: 1 | -1) {
461474
if (!enabled()) return
462-
const tab = cycleSessionTab(state().tabs, current(), direction, (tab) =>
475+
const tab = cycleSessionTab(ordered(), current(), direction, (tab) =>
463476
Boolean(status(tab.sessionID).unread || status(tab.sessionID).attention),
464477
)
465478
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
466479
},
467480
selectIndex(index: number) {
468481
if (!enabled()) return
469-
const tab = state().tabs[index]
482+
const tab = ordered()[index]
470483
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
471484
},
472485
}

‎packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
160160
detail(sessionID) {
161161
return FIXTURE_TABS.find((tab) => tab.sessionID === sessionID)?.project
162162
},
163+
group(sessionID) {
164+
return FIXTURE_TABS.find((tab) => tab.sessionID === sessionID)?.project
165+
},
163166
status(sessionID) {
164167
return statuses()[sessionID] ?? EMPTY_SESSION_TAB_STATUS
165168
},

‎packages/tui/test/context/session-tabs-model.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import {
33
adaptiveSessionTabLayout,
44
closeSessionTab,
55
cycleSessionTab,
6+
groupSessionTabs,
67
moveSessionTab,
78
moveSessionTabHistory,
89
openSessionTab,
910
recordClosedSessionTab,
1011
recordSessionTabHistory,
1112
reopenSessionTab,
1213
seedSessionTabMotion,
14+
sessionTabChildren,
1315
sessionTabComplete,
1416
sessionTabDetail,
1517
sessionTabOverflowWidth,
@@ -25,6 +27,27 @@ describe("session tabs", () => {
2527
expect(sessionTabDetail("opencode", undefined, "main", true)).toBe("opencode")
2628
})
2729

30+
test("groups tabs by project in first-appearance order", () => {
31+
const tabs = ["a", "b", "c", "d"].map((sessionID) => ({ sessionID }))
32+
const project = { a: "web", b: "api", c: "web" } as Record<string, string>
33+
expect(groupSessionTabs(tabs, (id) => project[id]).map((tab) => tab.sessionID)).toEqual(["a", "c", "b", "d"])
34+
})
35+
36+
test("nests session descendants in creation order", () => {
37+
expect(
38+
sessionTabChildren("root", [
39+
{ id: "root", created: 0 },
40+
{ id: "late", parentID: "root", created: 3 },
41+
{ id: "early", parentID: "root", created: 1 },
42+
{ id: "grandchild", parentID: "early", created: 2 },
43+
]),
44+
).toEqual([
45+
{ sessionID: "early", prefix: "├─ " },
46+
{ sessionID: "grandchild", prefix: "│ └─ " },
47+
{ sessionID: "late", prefix: "└─ " },
48+
])
49+
})
50+
2851
test("labels tabs by ordinal", () => {
2952
expect(Array.from({ length: 12 }, (_, index) => sessionTabNumberLabel(index))).toEqual([
3053
"1",

‎packages/tui/test/context/session-tabs.test.tsx‎

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ async function renderSessionTabs(
4141
newLocation?: "launch" | "inherit"
4242
launchDirectory?: string
4343
tabsEnabled?: boolean
44+
layout?: "horizontal" | "vertical"
45+
sessionProjects?: Record<string, string>
4446
viewFailures?: number
4547
experimental?: Record<string, boolean>
4648
},
@@ -120,7 +122,7 @@ async function renderSessionTabs(
120122
id: sessionID,
121123
parentID: options?.sessionParents?.[sessionID],
122124
title: sessionID === initialSessionID ? options?.title : undefined,
123-
projectID: "project",
125+
projectID: options?.sessionProjects?.[sessionID] ?? "project",
124126
location: { directory: options?.sessionDirectories?.[sessionID] ?? directory },
125127
cost: 0,
126128
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
@@ -135,7 +137,10 @@ async function renderSessionTabs(
135137
let storage!: ReturnType<typeof useStorage>
136138
let config!: ReturnType<typeof useConfig>
137139
let configuration = {
138-
tabs: { mode: options?.tabsEnabled === false ? ("off" as const) : ("on" as const) },
140+
tabs: {
141+
mode: options?.tabsEnabled === false ? ("off" as const) : ("on" as const),
142+
layout: options?.layout,
143+
},
139144
experimental: options?.experimental,
140145
session: { new_location: options?.newLocation ?? "launch" },
141146
}
@@ -266,7 +271,7 @@ test("loads VCS metadata for each persisted tab location", async () => {
266271
})
267272

268273
test("opens a background tab without changing the current session", async () => {
269-
const setup = await renderSessionTabs("first")
274+
const setup = await renderSessionTabs("first", { layout: "horizontal" })
270275

271276
try {
272277
await wait(() => setup.tabs.current() === "first" && setup.tabs.tabs().some((tab) => tab.sessionID === "first"))
@@ -281,6 +286,29 @@ test("opens a background tab without changing the current session", async () =>
281286
}
282287
})
283288

289+
test("groups vertical tabs by project and ignores manual moves", async () => {
290+
const setup = await renderSessionTabs("first", {
291+
persisted: ["first", "second", "third"],
292+
sessionProjects: { first: "web", second: "api", third: "web" },
293+
})
294+
295+
try {
296+
await wait(() => setup.data.session.get("third") !== undefined && setup.data.session.get("second") !== undefined)
297+
await wait(
298+
() =>
299+
setup.tabs
300+
.tabs()
301+
.map((tab) => tab.sessionID)
302+
.join() === "first,third,second",
303+
)
304+
setup.tabs.move("second", 0)
305+
await Bun.sleep(50)
306+
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first", "third", "second"])
307+
} finally {
308+
await setup.destroy()
309+
}
310+
})
311+
284312
test("loads location metadata when an open session moves", async () => {
285313
const destination = `${directory}/moved-worktree`
286314
const setup = await renderSessionTabs("first")

0 commit comments

Comments
 (0)