mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 00:31:51 +03:00
feat(tui): start fresh chats in launch workspace
This commit is contained in:
+4
-3
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user