feat(tui): distinguish cross-workspace sessions

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent 0d54ad96e2
commit 4858da0759
2 changed files with 107 additions and 0 deletions
+64
View File
@@ -62,4 +62,68 @@ describe("SessionMenu", () => {
"Release checklist",
)
})
test("shows compact workspace names only when they distinguish sessions", async () => {
setup = await createTestRenderer({ width: 100, height: 18, screenMode: "alternate-screen" })
const menu = new SessionMenu(setup.renderer, {
text: "#FFFFFF",
muted: "#999999",
border: "#555555",
})
setup.renderer.root.add(menu.root)
const scoped = sessions.map((session) => ({
...session,
workspaceScope: {
project_path: "/work/nanobot",
project_name: "nanobot",
access_mode: "restricted" as const,
},
}))
menu.open(scoped, "one", 6)
await setup.renderOnce()
expect(setup.captureCharFrame()).not.toContain("nanobot · Codex")
menu.open([
scoped[0]!,
{
...scoped[1]!,
workspaceScope: {
project_path: "C:\\work\\desktop",
project_name: "desktop",
access_mode: "restricted",
},
},
], "one", 6)
await setup.renderOnce()
const frame = setup.captureCharFrame()
expect(frame).toContain("nanobot · Codex")
expect(frame).toContain("desktop")
menu.update("desktop", 6)
expect(menu.choose()?.chatId).toBe("two")
menu.open([
{
...scoped[0]!,
workspaceScope: {
project_path: "/work/frontend/nanobot",
project_name: "nanobot",
access_mode: "restricted",
},
},
{
...scoped[1]!,
workspaceScope: {
project_path: "/work/backend/nanobot",
project_name: "nanobot",
access_mode: "restricted",
},
},
], "one", 6)
await setup.renderOnce()
const duplicates = setup.captureCharFrame()
expect(duplicates).toContain("frontend/nanobot")
expect(duplicates).toContain("backend/nanobot")
})
})
+43
View File
@@ -25,6 +25,8 @@ function updatedLabel(value: string | null): string {
export class SessionMenu {
readonly root: BoxRenderable
private readonly picker: PickerMenu<SessionMenuRow>
private readonly workspaceLabels = new Map<string, string>()
private showWorkspaces = false
constructor(
renderer: CliRenderer,
@@ -38,11 +40,14 @@ export class SessionMenu {
session.modelPreset || "",
session.preview,
session.chatId,
session.workspaceScope?.project_name || "",
session.workspaceScope?.project_path || "",
].join(" "),
render: (session) => {
const age = updatedLabel(session.updatedAt)
const preview = session.preview.trim()
const detail = [
this.showWorkspaces ? this.workspaceLabel(session) : "",
session.modelPreset,
age,
preview && preview !== sessionLabel(session) ? preview : "",
@@ -63,6 +68,7 @@ export class SessionMenu {
}
open(sessions: SessionSummary[], currentChatId: string, limit: number): void {
this.prepareWorkspaceLabels(sessions)
const rows = sessions
.map((session) => ({ ...session, active: session.chatId === currentChatId }))
.sort((left, right) => {
@@ -92,4 +98,41 @@ export class SessionMenu {
setTheme(theme: PickerMenuTheme): void {
this.picker.setTheme(theme)
}
private prepareWorkspaceLabels(sessions: SessionSummary[]): void {
this.workspaceLabels.clear()
const scopes = sessions.flatMap((session) => {
const path = normalizeWorkspacePath(session.workspaceScope?.project_path)
return path ? [{ path, name: session.workspaceScope?.project_name?.trim() || pathName(path) }] : []
})
const paths = new Set(scopes.map(({ path }) => path))
this.showWorkspaces = paths.size > 1
if (!this.showWorkspaces) return
const namePaths = new Map<string, Set<string>>()
for (const { path, name } of scopes) {
const pathsForName = namePaths.get(name) || new Set<string>()
pathsForName.add(path)
namePaths.set(name, pathsForName)
}
for (const { path, name } of scopes) {
this.workspaceLabels.set(path, namePaths.get(name)?.size === 1 ? name : shortPath(path))
}
}
private workspaceLabel(session: SessionSummary): string {
return this.workspaceLabels.get(normalizeWorkspacePath(session.workspaceScope?.project_path)) || ""
}
}
function normalizeWorkspacePath(value: string | undefined): string {
return (value || "").trim().replace(/\\/gu, "/").replace(/\/+$/u, "")
}
function pathName(path: string): string {
return path.split("/").filter(Boolean).at(-1) || path
}
function shortPath(path: string): string {
const parts = path.split("/").filter(Boolean)
return parts.slice(-2).join("/") || path
}