feat(tui): start fresh chats in launch workspace

This commit is contained in:
chengyongru
2026-08-20 11:16:09 +08:00
committed by chengyongru
parent 1018bdb7fe
commit c615aee2ca
11 changed files with 85 additions and 105 deletions
+4 -3
View File
@@ -59,7 +59,6 @@ import {
type TranscriptNavigation,
type TranscriptTheme,
} from "./transcript"
import { rememberChat } from "./session-state"
import { ComposerDraft } from "./composer-draft"
import { BranchMenu, branchPoints } from "./branch-menu"
import {
@@ -95,7 +94,6 @@ interface AppOptions {
version: string
access: string
theme: "auto" | ThemeMode
statePath?: string
}
interface ChatClient {
@@ -508,6 +506,10 @@ export class NanobotTui {
}
: { url: options.wsUrl }),
chatId: options.chatId,
initialWorkspaceScope: {
project_path: options.workspace,
access_mode: options.access.toLocaleLowerCase().includes("full") ? "full" : "restricted",
},
onEvent: (event) => this.accept(event),
onStatus: (status, detail) => this.handleStatus(status, detail),
})
@@ -914,7 +916,6 @@ export class NanobotTui {
accept(event: InboundEvent): void {
if (event.event === "attached") {
void rememberChat(this.options.statePath, event.chat_id)
this.host.reportSession(event.chat_id)
if (event.usage) this.lastUsage = event.usage
if (event.model_preset !== undefined) {
-1
View File
@@ -32,7 +32,6 @@ const options: AppOptions = {
version: process.env.NANOBOT_TUI_VERSION?.trim() || "dev",
access: process.env.NANOBOT_TUI_ACCESS?.trim() || "workspace access",
theme: themePreference(),
statePath: process.env.NANOBOT_TUI_STATE_PATH?.trim() || undefined,
}
let app: NanobotTui | undefined
+45
View File
@@ -259,6 +259,10 @@ describe("gateway protocol", () => {
const client = new NanobotClient({
url: "ws://nanobot.test/ws",
chatId: "terminal",
initialWorkspaceScope: {
project_path: "/tmp/project",
access_mode: "restricted",
},
onEvent: (event) => events.push(event),
onStatus: () => undefined,
})
@@ -324,6 +328,47 @@ describe("gateway protocol", () => {
}
})
test("starts a fresh chat in the launch workspace", () => {
const original = globalThis.WebSocket
let socket: FakeSocket | undefined
Object.defineProperty(globalThis, "WebSocket", {
configurable: true,
value: class extends FakeSocket {
constructor() {
super()
socket = this
}
},
})
try {
const client = new NanobotClient({
url: "ws://nanobot.test/ws",
initialWorkspaceScope: {
project_path: "/tmp/launch-project",
access_mode: "restricted",
},
onEvent: () => undefined,
onStatus: () => undefined,
})
client.connect()
if (!socket) throw new Error("socket was not created")
socket.emit("message", {
data: JSON.stringify({ event: "ready", chat_id: "", client_id: "client" }),
})
expect(socket.sent.map((value) => JSON.parse(value))).toEqual([{
type: "new_chat",
workspace_scope: {
project_path: "/tmp/launch-project",
access_mode: "restricted",
},
}])
} finally {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
}
})
test("loads runtime model and access controls from canonical APIs", async () => {
const original = globalThis.fetch
globalThis.fetch = (async (input: string | URL | Request) => {
+2 -1
View File
@@ -157,6 +157,7 @@ export interface ClientOptions {
connectionRetryLabel?: string
startupRetryMaxDelayMs?: number
chatId?: string
initialWorkspaceScope?: WorkspaceScopePayload
reconnectDelayMs?: number
onEvent: (event: InboundEvent) => void
onStatus: (status: ConnectionStatus, detail?: string) => void
@@ -995,7 +996,7 @@ export class NanobotClient {
this.chatId = requestedChatId
this.write({ type: "attach", chat_id: this.chatId })
} else {
this.write({ type: "new_chat" })
this.newChat(this.options.initialWorkspaceScope)
}
} else if (event.event === "attached") {
this.chatId = event.chat_id
-27
View File
@@ -1,27 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import { mkdtemp, readFile, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { rememberChat } from "./session-state"
describe("TUI session state", () => {
let directory = ""
afterEach(async () => {
if (directory) await rm(directory, { recursive: true, force: true })
directory = ""
})
test("atomically remembers the last attached gateway chat", async () => {
directory = await mkdtemp(join(tmpdir(), "nanobot-tui-state-"))
const path = join(directory, "nested", "state.json")
await rememberChat(path, "chat-123")
expect(JSON.parse(await readFile(path, "utf8"))).toEqual({
schema_version: 1,
chat_id: "chat-123",
})
})
})
-23
View File
@@ -1,23 +0,0 @@
import { mkdir, rename, rm } from "node:fs/promises"
import { dirname } from "node:path"
export async function rememberChat(path: string | undefined, chatId: string): Promise<void> {
if (!path || !chatId) return
const temporary = `${path}.tmp-${process.pid}`
try {
await mkdir(dirname(path), { recursive: true })
const content = `${JSON.stringify({ schema_version: 1, chat_id: chatId })}\n`
await Bun.write(temporary, content)
try {
await rename(temporary, path)
} catch {
// Windows cannot always atomically replace an existing destination.
await Bun.write(path, content)
await rm(temporary, { force: true })
}
} catch {
// Session navigation must keep working when this optional convenience file
// is read-only, on a network volume, or removed during shutdown.
await rm(temporary, { force: true }).catch(() => {})
}
}