diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index 1492ac000b6..a3a3541ef5f 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -222,6 +222,88 @@ describe("handleSendChat", () => { expect(onSlashAction).toHaveBeenCalledWith("refresh-tools-effective"); }); + it("sends /btw immediately while a main run is active without queueing it", async () => { + const request = vi.fn(async (method: string) => { + if (method === "chat.send") { + return {}; + } + throw new Error(`Unexpected request: ${method}`); + }); + const host = makeHost({ + client: { request } as unknown as ChatHost["client"], + chatRunId: "run-main", + chatStream: "Working...", + chatMessage: "/btw what changed?", + }); + + await handleSendChat(host); + + expect(request).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + sessionKey: "agent:main", + message: "/btw what changed?", + deliver: false, + idempotencyKey: expect.any(String), + }), + ); + expect(host.chatQueue).toEqual([]); + expect(host.chatRunId).toBe("run-main"); + expect(host.chatStream).toBe("Working..."); + expect(host.chatMessages).toEqual([]); + expect(host.chatMessage).toBe(""); + }); + + it("sends /btw without adopting a main chat run when idle", async () => { + const request = vi.fn(async (method: string) => { + if (method === "chat.send") { + return {}; + } + throw new Error(`Unexpected request: ${method}`); + }); + const host = makeHost({ + client: { request } as unknown as ChatHost["client"], + chatMessage: "/btw summarize this", + }); + + await handleSendChat(host); + + expect(request).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + message: "/btw summarize this", + deliver: false, + }), + ); + expect(host.chatRunId).toBeNull(); + expect(host.chatMessages).toEqual([]); + expect(host.chatMessage).toBe(""); + }); + + it("restores the BTW draft when detached send fails", async () => { + const host = makeHost({ + client: { + request: vi.fn(async (method: string) => { + if (method === "chat.send") { + throw new Error("network down"); + } + throw new Error(`Unexpected request: ${method}`); + }), + } as unknown as ChatHost["client"], + chatRunId: "run-main", + chatStream: "Working...", + chatMessage: "/btw what changed?", + }); + + await handleSendChat(host); + + expect(host.chatQueue).toEqual([]); + expect(host.chatRunId).toBe("run-main"); + expect(host.chatStream).toBe("Working..."); + expect(host.chatMessage).toBe("/btw what changed?"); + expect(host.lastError).toContain("network down"); + }); + it("shows a visible pending item for /steer on the active run", async () => { vi.doMock("./chat/slash-command-executor.ts", async () => { const actual = await vi.importActual( diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index cde3114d852..ce91bd092a5 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -7,6 +7,7 @@ import { abortChatRun, loadChatHistory, sendChatMessage, + sendDetachedChatMessage, type ChatState, } from "./controllers/chat.ts"; import { loadModels } from "./controllers/models.ts"; @@ -81,6 +82,10 @@ function isChatResetCommand(text: string) { return normalized.startsWith("/new ") || normalized.startsWith("/reset "); } +function isBtwCommand(text: string) { + return /^\/btw(?::|\s|$)/i.test(text.trim()); +} + export async function handleAbortChat(host: ChatHost) { if (!host.connected) { return; @@ -177,6 +182,36 @@ async function sendChatMessageNow( return ok; } +async function sendDetachedBtwMessage( + host: ChatHost, + message: string, + opts?: { + previousDraft?: string; + attachments?: ChatAttachment[]; + previousAttachments?: ChatAttachment[]; + }, +) { + const runId = await sendDetachedChatMessage( + host as unknown as ChatState, + message, + opts?.attachments, + ); + const ok = Boolean(runId); + if (!ok && opts?.previousDraft != null) { + host.chatMessage = opts.previousDraft; + } + if (!ok && opts?.previousAttachments) { + host.chatAttachments = opts.previousAttachments; + } + if (ok) { + setLastActiveSessionKey( + host as unknown as Parameters[0], + host.sessionKey, + ); + } + return ok; +} + async function flushChatQueue(host: ChatHost) { if (!host.connected || isChatBusy(host)) { return; @@ -243,6 +278,19 @@ export async function handleSendChat( return; } + if (isBtwCommand(message)) { + if (messageOverride == null) { + host.chatMessage = ""; + host.chatAttachments = []; + } + await sendDetachedBtwMessage(host, message, { + previousDraft: messageOverride == null ? previousDraft : undefined, + attachments: hasAttachments ? attachmentsToSend : undefined, + previousAttachments: messageOverride == null ? attachments : undefined, + }); + return; + } + // Intercept local slash commands (/status, /model, /compact, etc.) const parsed = parseSlashCommand(message); if (parsed?.command.executeLocal) { diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index cd56171ad74..9a90e840a8b 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -142,6 +142,38 @@ function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string } return { mimeType: match[1], content: match[2] }; } +function buildApiAttachments(attachments?: ChatAttachment[]) { + const hasAttachments = attachments && attachments.length > 0; + return hasAttachments + ? attachments + .map((att) => { + const parsed = dataUrlToBase64(att.dataUrl); + if (!parsed) { + return null; + } + return { + type: "image", + mimeType: parsed.mimeType, + content: parsed.content, + }; + }) + .filter((a): a is NonNullable => a !== null) + : undefined; +} + +async function requestChatSend( + state: ChatState, + params: { message: string; attachments?: ChatAttachment[]; runId: string }, +) { + await state.client!.request("chat.send", { + sessionKey: state.sessionKey, + message: params.message, + deliver: false, + idempotencyKey: params.runId, + attachments: buildApiAttachments(params.attachments), + }); +} + type AssistantMessageNormalizationOptions = { roleRequirement: "required" | "optional"; roleCaseSensitive?: boolean; @@ -238,31 +270,8 @@ export async function sendChatMessage( state.chatStream = ""; state.chatStreamStartedAt = now; - // Convert attachments to API format - const apiAttachments = hasAttachments - ? attachments - .map((att) => { - const parsed = dataUrlToBase64(att.dataUrl); - if (!parsed) { - return null; - } - return { - type: "image", - mimeType: parsed.mimeType, - content: parsed.content, - }; - }) - .filter((a): a is NonNullable => a !== null) - : undefined; - try { - await state.client.request("chat.send", { - sessionKey: state.sessionKey, - message: msg, - deliver: false, - idempotencyKey: runId, - attachments: apiAttachments, - }); + await requestChatSend(state, { message: msg, attachments, runId }); return runId; } catch (err) { const error = formatConnectError(err); @@ -284,6 +293,30 @@ export async function sendChatMessage( } } +export async function sendDetachedChatMessage( + state: ChatState, + message: string, + attachments?: ChatAttachment[], +): Promise { + if (!state.client || !state.connected) { + return null; + } + const msg = message.trim(); + const hasAttachments = attachments && attachments.length > 0; + if (!msg && !hasAttachments) { + return null; + } + state.lastError = null; + const runId = generateUUID(); + try { + await requestChatSend(state, { message: msg, attachments, runId }); + return runId; + } catch (err) { + state.lastError = formatConnectError(err); + return null; + } +} + export async function abortChatRun(state: ChatState): Promise { if (!state.client || !state.connected) { return false;