mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
fix(tui): allow switching active sessions
This commit is contained in:
@@ -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
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user