diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index 375284748..8d52a4fbf 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -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 () => { setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" }) const original = globalThis.fetch diff --git a/tui/src/app.ts b/tui/src/app.ts index 60a2c6363..389548bce 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -407,7 +407,8 @@ export class NanobotTui { private readonly meta: TextRenderable private readonly host: TuiHost private readonly draft = new ComposerDraft() - private readonly promptQueue = new PromptQueue() + private readonly promptQueues = new Map() + private currentChatId = "" private palette: Palette private activeThemeMode: ThemeMode private backgroundKnown: boolean @@ -959,6 +960,8 @@ export class NanobotTui { accept(event: InboundEvent): void { 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) if (event.usage) this.lastUsage = event.usage if (event.model_preset !== undefined) { @@ -983,6 +986,8 @@ export class NanobotTui { if (hydrationId !== this.hydrationId) return this.applyRecoveryState(event.recovery_state ?? null) this.flushPendingEvents() + this.syncQueuePreview() + if (switchedSession) this.sendNextFollowUp() }) return } @@ -1400,6 +1405,7 @@ export class NanobotTui { } private renderActiveStatus(): void { + if (this.sessionLoading || this.sessionMenu.visible) return const elapsed = formatElapsed(Date.now() - this.activeStartedAt) const navigation = this.transcriptNavigation.awayFromBottom ? " · Ctrl+End latest" : "" const queued = this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : "" @@ -1429,6 +1435,16 @@ export class NanobotTui { 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 { const queued = this.promptQueue.restore() if (!queued.length) return @@ -2093,10 +2109,6 @@ export class NanobotTui { } private async openSessions(): Promise { - if (this.activeTurn) { - this.status.content = "Wait for the current turn or press Ctrl+C" - return - } this.commandMenu.hide() this.dismissRuntimeControls() this.mentionMenu.hide() @@ -2139,10 +2151,6 @@ export class NanobotTui { } 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) { this.sessionTitle = sessionLabel(session) this.applySessionModel(session) @@ -2150,7 +2158,6 @@ export class NanobotTui { this.applyRecoveryState(session.recoveryState ?? null) this.updateTitle() this.closeSessions() - this.status.content = this.readyStatus() return } if (!this.ready) { @@ -2160,8 +2167,10 @@ export class NanobotTui { this.closeSessions() try { this.ready = false + this.activeTurnId = null + this.setActive(false) this.clearRecoveryState() - this.clearPromptQueue() + this.queuePreview.update([]) this.sessionMetadataId += 1 this.clearHostContext() this.sessionTitle = sessionLabel(session) @@ -2301,7 +2310,8 @@ export class NanobotTui { this.clearComposer() this.syncComposerPlaceholder() 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() }