fix(ui): send btw immediately during active runs

This commit is contained in:
Nimrod Gutman
2026-04-10 14:54:02 +03:00
parent f989927174
commit 9e2adb3ea8
3 changed files with 187 additions and 24 deletions

View File

@@ -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<typeof import("./chat/slash-command-executor.ts")>(

View File

@@ -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<typeof setLastActiveSessionKey>[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) {

View File

@@ -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<typeof a> => 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<typeof a> => 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<string | null> {
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<boolean> {
if (!state.client || !state.connected) {
return false;