fix(tui): allow switching active sessions

This commit is contained in:
Xubin Ren
2026-08-23 21:48:20 +08:00
parent 0f4c9956a8
commit 476bc7f4dc
2 changed files with 113 additions and 12 deletions
+91
View File
@@ -567,6 +567,97 @@ describe("NanobotTui layout", () => {
} }
}) })
test("switches away from a running session without losing its queued follow-ups", async () => {
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
const original = globalThis.fetch
globalThis.fetch = ((input: string | URL | Request) => {
const url = String(input)
if (url.endsWith("/api/sessions")) {
return Promise.resolve(new Response(JSON.stringify({
sessions: [
{ key: "websocket:chat", title: "Running chat", run_started_at: 1_700_000_000 },
{ key: "websocket:other", title: "Other chat" },
],
})))
}
if (url.endsWith("/api/webui/sidebar-state")) {
return Promise.resolve(new Response(JSON.stringify({})))
}
return Promise.resolve(new Response(JSON.stringify({
messages: [],
page: { has_more_before: false },
})))
}) as typeof fetch
const sent: string[] = []
const attached: string[] = []
let activeChatId = "chat"
const base = client(sent, attached)
const transport = {
...base,
get activeChatId() { return activeChatId },
attach(chatId: string) {
attached.push(chatId)
activeChatId = chatId
},
}
const app = NanobotTui.mount(
setup.renderer,
{ ...options, apiUrl: "http://nanobot.test", apiToken: "secret" },
transport,
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
)
const ui = app as unknown as {
ready: boolean
activeTurn: boolean
composer: TextareaRenderable
sessionMenu: { visible: boolean }
queuePreview: { root: { visible: boolean } }
status: { plainText: string }
}
try {
app.accept({ event: "attached", chat_id: "chat" })
await waitUntil(() => ui.ready)
app.accept({ event: "goal_status", chat_id: "chat", status: "running", turn_id: "turn" })
ui.composer.setText("follow up in chat")
setup.mockInput.pressTab()
await waitUntil(() => ui.composer.plainText === "")
expect(ui.queuePreview.root.visible).toBe(true)
ui.composer.setText("/sessions")
ui.composer.submit()
await waitUntil(() => ui.sessionMenu.visible)
await Bun.sleep(120)
expect(ui.status.plainText).toContain("2 sessions")
ui.composer.setText("other")
ui.composer.submit()
await waitUntil(() => attached.at(-1) === "other")
app.accept({ event: "attached", chat_id: "other" })
await waitUntil(() => ui.ready)
expect(ui.activeTurn).toBe(false)
expect(ui.queuePreview.root.visible).toBe(false)
ui.composer.setText("/sessions")
ui.composer.submit()
await waitUntil(() => ui.sessionMenu.visible)
ui.composer.setText("running")
ui.composer.submit()
await waitUntil(() => attached.at(-1) === "chat")
app.accept({ event: "attached", chat_id: "chat" })
app.accept({ event: "goal_status", chat_id: "chat", status: "running", turn_id: "turn" })
await waitUntil(() => ui.ready && ui.activeTurn)
expect(ui.queuePreview.root.visible).toBe(true)
expect(sent).toEqual([])
app.accept({ event: "turn_end", chat_id: "chat", turn_id: "turn" })
await waitUntil(() => sent.length === 1)
expect(sent).toEqual(["follow up in chat"])
} finally {
globalThis.fetch = original
}
})
test("refreshes expired API credentials before opening sessions", async () => { test("refreshes expired API credentials before opening sessions", async () => {
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" }) setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
const original = globalThis.fetch const original = globalThis.fetch
+22 -12
View File
@@ -407,7 +407,8 @@ export class NanobotTui {
private readonly meta: TextRenderable private readonly meta: TextRenderable
private readonly host: TuiHost private readonly host: TuiHost
private readonly draft = new ComposerDraft() private readonly draft = new ComposerDraft()
private readonly promptQueue = new PromptQueue() private readonly promptQueues = new Map<string, PromptQueue>()
private currentChatId = ""
private palette: Palette private palette: Palette
private activeThemeMode: ThemeMode private activeThemeMode: ThemeMode
private backgroundKnown: boolean private backgroundKnown: boolean
@@ -959,6 +960,8 @@ export class NanobotTui {
accept(event: InboundEvent): void { accept(event: InboundEvent): void {
if (event.event === "attached") { if (event.event === "attached") {
const switchedSession = Boolean(this.currentChatId && this.currentChatId !== event.chat_id)
this.currentChatId = event.chat_id
this.host.reportSession(event.chat_id) this.host.reportSession(event.chat_id)
if (event.usage) this.lastUsage = event.usage if (event.usage) this.lastUsage = event.usage
if (event.model_preset !== undefined) { if (event.model_preset !== undefined) {
@@ -983,6 +986,8 @@ export class NanobotTui {
if (hydrationId !== this.hydrationId) return if (hydrationId !== this.hydrationId) return
this.applyRecoveryState(event.recovery_state ?? null) this.applyRecoveryState(event.recovery_state ?? null)
this.flushPendingEvents() this.flushPendingEvents()
this.syncQueuePreview()
if (switchedSession) this.sendNextFollowUp()
}) })
return return
} }
@@ -1400,6 +1405,7 @@ export class NanobotTui {
} }
private renderActiveStatus(): void { private renderActiveStatus(): void {
if (this.sessionLoading || this.sessionMenu.visible) return
const elapsed = formatElapsed(Date.now() - this.activeStartedAt) const elapsed = formatElapsed(Date.now() - this.activeStartedAt)
const navigation = this.transcriptNavigation.awayFromBottom ? " · Ctrl+End latest" : "" const navigation = this.transcriptNavigation.awayFromBottom ? " · Ctrl+End latest" : ""
const queued = this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : "" const queued = this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : ""
@@ -1429,6 +1435,16 @@ export class NanobotTui {
this.sendPrompt(prompt) this.sendPrompt(prompt)
} }
private get promptQueue(): PromptQueue {
const chatId = this.currentChatId || this.client.activeChatId
let queue = this.promptQueues.get(chatId)
if (!queue) {
queue = new PromptQueue()
this.promptQueues.set(chatId, queue)
}
return queue
}
private restoreQueuedPrompts(): void { private restoreQueuedPrompts(): void {
const queued = this.promptQueue.restore() const queued = this.promptQueue.restore()
if (!queued.length) return if (!queued.length) return
@@ -2093,10 +2109,6 @@ export class NanobotTui {
} }
private async openSessions(): Promise<void> { private async openSessions(): Promise<void> {
if (this.activeTurn) {
this.status.content = "Wait for the current turn or press Ctrl+C"
return
}
this.commandMenu.hide() this.commandMenu.hide()
this.dismissRuntimeControls() this.dismissRuntimeControls()
this.mentionMenu.hide() this.mentionMenu.hide()
@@ -2139,10 +2151,6 @@ export class NanobotTui {
} }
private switchSession(session: SessionSummary): void { private switchSession(session: SessionSummary): void {
if (this.activeTurn) {
this.status.content = "Wait for the current turn or press Ctrl+C"
return
}
if (session.chatId === this.client.activeChatId) { if (session.chatId === this.client.activeChatId) {
this.sessionTitle = sessionLabel(session) this.sessionTitle = sessionLabel(session)
this.applySessionModel(session) this.applySessionModel(session)
@@ -2150,7 +2158,6 @@ export class NanobotTui {
this.applyRecoveryState(session.recoveryState ?? null) this.applyRecoveryState(session.recoveryState ?? null)
this.updateTitle() this.updateTitle()
this.closeSessions() this.closeSessions()
this.status.content = this.readyStatus()
return return
} }
if (!this.ready) { if (!this.ready) {
@@ -2160,8 +2167,10 @@ export class NanobotTui {
this.closeSessions() this.closeSessions()
try { try {
this.ready = false this.ready = false
this.activeTurnId = null
this.setActive(false)
this.clearRecoveryState() this.clearRecoveryState()
this.clearPromptQueue() this.queuePreview.update([])
this.sessionMetadataId += 1 this.sessionMetadataId += 1
this.clearHostContext() this.clearHostContext()
this.sessionTitle = sessionLabel(session) this.sessionTitle = sessionLabel(session)
@@ -2301,7 +2310,8 @@ export class NanobotTui {
this.clearComposer() this.clearComposer()
this.syncComposerPlaceholder() this.syncComposerPlaceholder()
this.composer.focus() this.composer.focus()
if (!this.activeTurn && this.ready) this.status.content = this.readyStatus() if (this.activeTurn) this.renderActiveStatus()
else if (this.ready) this.status.content = this.readyStatus()
this.updateMeta() this.updateMeta()
} }