feat: prompt queue

This commit is contained in:
abeatrix
2026-03-24 12:46:22 -07:00
parent 6ee6e51510
commit 4120afc7bb
21 changed files with 2336 additions and 1186 deletions
+6
View File
@@ -204,9 +204,11 @@ function ChatThreadPane({
error,
summary,
fileDiffs,
promptsInQueue,
pendingToolApprovals,
setConfig,
sendPrompt,
steerPromptInQueue,
approveToolApproval,
rejectToolApproval,
reset,
@@ -773,6 +775,9 @@ function ChatThreadPane({
}))
}
onPromptInputChange={setPromptInput}
onSteerPromptInQueue={(promptId) => {
void steerPromptInQueue(promptId);
}}
onProviderChange={(nextProvider) =>
setConfig((prev) => {
const selected = providerCredentials[nextProvider];
@@ -798,6 +803,7 @@ function ChatThreadPane({
gitBranch={gitBranch}
model={config.model}
mode={config.mode}
promptsInQueue={promptsInQueue}
promptInput={promptInput}
provider={config.provider}
status={status}
+2 -2
View File
@@ -29,13 +29,13 @@ export const MemoizedMarkdown = memo(
const blocks = useMemo(() => parseMarkdownIntoBlocks(content), [content]);
const occurrences = new Map<string, number>();
return blocks.map((block) => {
return blocks.map((block, index) => {
const occurrence = (occurrences.get(block) ?? 0) + 1;
occurrences.set(block, occurrence);
return (
<MemoizedMarkdownBlock
content={block}
key={`${id}-block_${occurrence}-${block.slice(0, 24)}`}
key={`${id}-block_${index}-${occurrence}`}
/>
);
});
@@ -21,6 +21,7 @@ import {
ComboboxList,
} from "@/components/ui/combobox";
import { useWorkspace } from "@/contexts/workspace-context";
import type { PromptInQueue } from "@/hooks/chat-session/types";
import type { ChatSessionStatus } from "@/lib/chat-schema";
import { desktopClient } from "@/lib/desktop-client";
import {
@@ -102,9 +103,11 @@ type ChatInputBarProps = {
onSend: () => void;
onAbort: () => void;
onReset: () => void;
promptsInQueue: PromptInQueue[];
attachments: Array<{ id: string; name: string; isImage: boolean }>;
onAttachFiles: (files: File[]) => void;
onRemoveAttachment: (id: string) => void;
onSteerPromptInQueue: (promptId: string) => void;
summary: {
toolCalls: number;
tokensIn: number;
@@ -129,9 +132,11 @@ export function ChatInputBar({
onSend,
onAbort,
onReset,
promptsInQueue,
attachments,
onAttachFiles,
onRemoveAttachment,
onSteerPromptInQueue,
summary,
}: ChatInputBarProps) {
const {
@@ -143,16 +148,16 @@ export function ChatInputBar({
} = useWorkspace();
const isBusy =
status === "starting" || status === "running" || status === "stopping";
const canAbort = isBusy;
const hasDraft = promptInput.trim().length > 0 || attachments.length > 0;
const canAbort = isBusy && !hasDraft;
const [modelSupportsReasoning, setModelSupportsReasoning] = useState(() =>
hasReasoningCapability(FALLBACK_PROVIDER_REASONING_MODELS, provider, model),
);
const canSend =
(promptInput.trim().length > 0 || attachments.length > 0) && !isBusy;
const canSend = hasDraft;
const effortLevels = ["Low", "Medium", "High"] as const;
const [effortIndex, setEffortIndex] = useState(1);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const promptInputRef = useRef<HTMLInputElement | null>(null);
const promptInputRef = useRef<HTMLTextAreaElement | null>(null);
const [cursorIndex, setCursorIndex] = useState(() => promptInput.length);
const [mentionOpen, setMentionOpen] = useState(false);
const [activeMention, setActiveMention] = useState<ActiveMention | null>(
@@ -182,6 +187,20 @@ export function ChatInputBar({
setCursorIndex((prev) => Math.min(prev, promptInput.length));
}, [promptInput.length]);
useEffect(() => {
const input = promptInputRef.current;
if (!input) {
return;
}
input.style.height = "0px";
const styles = window.getComputedStyle(input);
const lineHeight = Number.parseFloat(styles.lineHeight) || 20;
const maxHeight = lineHeight * 10;
const nextHeight = Math.min(input.scrollHeight, maxHeight);
input.style.height = `${nextHeight}px`;
input.style.overflowY = input.scrollHeight > maxHeight ? "auto" : "hidden";
}, []);
useEffect(() => {
const nextMention = getActiveMention(promptInput, cursorIndex);
setActiveMention(nextMention);
@@ -278,6 +297,58 @@ export function ChatInputBar({
<div className="border-t border-border bg-card">
{/* Input area */}
<div className="px-4 py-3">
{promptsInQueue.length > 0 && (
<div className="mb-3 rounded-lg border border-border bg-background/70 p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[11px] font-medium text-foreground">
Queued for upcoming turns
</div>
<div className="text-[10px] text-muted-foreground">
Steer runs first on the next turn
</div>
</div>
<div className="flex flex-col gap-1.5">
{promptsInQueue.map((item, index) => (
<div
className={cn(
"flex items-start justify-between gap-3 rounded-md border px-2.5 py-2",
item.steer
? "border-amber-300/60 bg-amber-500/8"
: "border-border/70 bg-muted/30",
)}
key={item.id}
>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2 text-[10px] uppercase tracking-wide text-muted-foreground">
<span>{item.steer ? "Steer" : `Queue ${index + 1}`}</span>
{item.steer ? (
<span className="rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[9px] font-medium text-amber-700">
Next turn
</span>
) : null}
</div>
<div className="line-clamp-2 text-xs text-foreground whitespace-pre-wrap wrap-break-word">
{item.prompt}
</div>
</div>
{!item.steer ? (
<button
className="shrink-0 rounded-md border border-border px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => onSteerPromptInQueue(item.id)}
type="button"
>
Steer
</button>
) : (
<div className="shrink-0 text-[10px] text-amber-700">
Steering
</div>
)}
</div>
))}
</div>
</div>
)}
<div className="relative">
{mentionOpen && (
<div className="absolute inset-x-0 bottom-full z-50 mb-1 max-h-56 overflow-y-auto rounded-lg border border-border bg-popover p-1 shadow-xl">
@@ -311,9 +382,9 @@ export function ChatInputBar({
)}
</div>
)}
<div className="flex items-center gap-2 rounded-lg border border-border bg-background px-3 py-2.5 focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20 transition-all">
<input
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
<div className="flex items-end gap-2 rounded-lg border border-border bg-background px-3 py-2.5 transition-all focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20">
<textarea
className="max-h-60 min-h-5 flex-1 resize-none bg-transparent text-sm leading-5 text-foreground placeholder:text-muted-foreground outline-none"
onChange={(e) => {
onPromptInputChange(e.target.value);
setCursorIndex(
@@ -355,10 +426,10 @@ export function ChatInputBar({
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (canAbort) {
onAbort();
} else if (canSend) {
if (canSend) {
onSend();
} else if (canAbort) {
onAbort();
}
}
}}
@@ -369,10 +440,11 @@ export function ChatInputBar({
}
placeholder={
isBusy
? "Agent is working..."
? "Agent is working... submit to queue another message"
: "Enter your question or type / for workflow or @ to attach files"
}
ref={promptInputRef}
rows={1}
value={promptInput}
/>
</div>
@@ -443,13 +515,13 @@ export function ChatInputBar({
<button
className="rounded-full bg-foreground p-1.5 text-background hover:bg-foreground/80 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
disabled={!canSend && !canAbort}
onClick={canAbort ? onAbort : onSend}
onClick={canSend ? onSend : onAbort}
type="button"
>
{canAbort ? (
<CircleStop className="h-4 w-4" />
) : (
{canSend ? (
<ArrowUp className="h-4 w-4" />
) : (
<CircleStop className="h-4 w-4" />
)}
</button>
</div>
@@ -13,7 +13,14 @@ import {
ShieldAlert,
Terminal,
} from "lucide-react";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { Button } from "@/components/ui/button";
import type { ChatMessage, ChatSessionStatus } from "@/lib/chat-schema";
import { cn } from "@/lib/utils";
@@ -50,6 +57,8 @@ type ToolApprovalRequestItem = {
};
const IS_DEBUG = process.env.NODE_ENV === "test";
const STICKY_BOTTOM_THRESHOLD_PX = 24;
const SCROLL_TO_BOTTOM_BUTTON_THRESHOLD_PX = 120;
function ChatMessagesImpl({
sessionId: _sessionId,
@@ -67,7 +76,7 @@ function ChatMessagesImpl({
onStartChat,
}: ChatMessagesProps) {
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
const hasAppliedInitialScrollRef = useRef(false);
const shouldStickToBottomRef = useRef(true);
const hasMessages = messages.length > 0;
const lastErrorMessage = [...messages]
.reverse()
@@ -95,6 +104,7 @@ function ChatMessagesImpl({
if (!viewport) {
return;
}
shouldStickToBottomRef.current = true;
viewport.scrollTo({ top: viewport.scrollHeight, behavior });
setShowScrollToBottom((prev) => (prev ? false : prev));
},
@@ -123,7 +133,10 @@ function ChatMessagesImpl({
const updateScrollToBottomVisibility = () => {
const distanceFromBottom =
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
const shouldShow = distanceFromBottom > 120;
shouldStickToBottomRef.current =
distanceFromBottom <= STICKY_BOTTOM_THRESHOLD_PX;
const shouldShow =
distanceFromBottom > SCROLL_TO_BOTTOM_BUTTON_THRESHOLD_PX;
setShowScrollToBottom((prev) =>
prev === shouldShow ? prev : shouldShow,
);
@@ -137,26 +150,12 @@ function ChatMessagesImpl({
};
}, [getViewport]);
useEffect(() => {
if (hasAppliedInitialScrollRef.current) {
useLayoutEffect(() => {
if (!shouldStickToBottomRef.current) {
return;
}
const hasScrollableContent =
messages.length > 0 || pendingToolApprovals.length > 0;
if (!hasScrollableContent) {
return;
}
const frame = window.requestAnimationFrame(() => {
scrollToBottom("auto");
hasAppliedInitialScrollRef.current = true;
});
return () => {
window.cancelAnimationFrame(frame);
};
}, [messages.length, pendingToolApprovals.length, scrollToBottom]);
scrollToBottom("auto");
}, [scrollToBottom]);
useEffect(() => {
const activeRequestIds = new Set(
@@ -67,6 +67,7 @@ export type ChatWsResponseEvent = {
sessionId?: string;
result?: ChatApiResult;
ok?: boolean;
queued?: boolean;
};
error?: string;
};
@@ -98,3 +99,9 @@ export type SerializedAttachments = {
userImages: string[];
userFiles: SerializedAttachmentFile[];
};
export type PromptInQueue = {
id: string;
prompt: string;
steer: boolean;
};
File diff suppressed because it is too large Load Diff
+503 -316
View File
@@ -33,31 +33,37 @@ import {
DEFAULT_RPC_CLIENT_TYPE,
type HostContext,
type JsonRecord,
type LiveSession,
type PromptInQueue,
type QueuedChatTurn,
type ToolApprovalRequestItem,
} from "./types";
// ---------------------------------------------------------------------------
// Config helpers
// ---------------------------------------------------------------------------
function getNestedString(obj: unknown, ...keys: string[]): string | undefined {
let current: unknown = obj;
for (const key of keys) {
if (!current || typeof current !== "object") return undefined;
current = (current as JsonRecord)[key];
}
return typeof current === "string" ? current : undefined;
}
function setRuntimeHomeDir(config: unknown) {
if (!config || typeof config !== "object") {
const homeDir = getNestedString(config, "sessions", "homeDir")?.trim();
if (homeDir) {
setHomeDir(homeDir);
} else {
setHomeDirIfUnset(homedir());
return;
}
const sessions = (config as JsonRecord).sessions;
const homeDir =
sessions && typeof sessions === "object"
? ((sessions as JsonRecord).homeDir as string | undefined)
: undefined;
const normalized = homeDir?.trim();
if (normalized) {
setHomeDir(normalized);
return;
}
setHomeDirIfUnset(homedir());
}
function addRuntimeLoggerContext(config: unknown) {
if (!config || typeof config !== "object") {
return;
}
if (!config || typeof config !== "object") return;
const record = config as JsonRecord;
const existing =
record.logger && typeof record.logger === "object"
@@ -67,6 +73,7 @@ function addRuntimeLoggerContext(config: unknown) {
existing.bindings && typeof existing.bindings === "object"
? { ...(existing.bindings as JsonRecord) }
: {};
record.logger = {
...existing,
name:
@@ -81,30 +88,35 @@ function addRuntimeLoggerContext(config: unknown) {
};
}
// ---------------------------------------------------------------------------
// Bridge script resolution
// ---------------------------------------------------------------------------
const BRIDGE_SCRIPT = "chat-runtime-bridge.ts";
const BRIDGE_SEARCH_DIRS = [
["apps", "code", "scripts"],
["packages", "app", "scripts"],
["app", "scripts"],
];
function resolveChatRuntimeBridgeScriptPath(ctx: HostContext): string | null {
const candidates = [
join(
ctx.workspaceRoot,
"apps",
"code",
"scripts",
"chat-runtime-bridge.ts",
),
join(
ctx.workspaceRoot,
"packages",
"app",
"scripts",
"chat-runtime-bridge.ts",
),
join(ctx.workspaceRoot, "app", "scripts", "chat-runtime-bridge.ts"),
join(process.cwd(), "app", "scripts", "chat-runtime-bridge.ts"),
join(process.cwd(), "..", "scripts", "chat-runtime-bridge.ts"),
join(process.cwd(), "scripts", "chat-runtime-bridge.ts"),
];
return candidates.find((candidate) => existsSync(candidate)) ?? null;
for (const segments of BRIDGE_SEARCH_DIRS) {
const candidate = join(ctx.workspaceRoot, ...segments, BRIDGE_SCRIPT);
if (existsSync(candidate)) return candidate;
}
for (const base of [process.cwd()]) {
for (const rel of [["app", "scripts"], ["..", "scripts"], ["scripts"]]) {
const candidate = join(base, ...rel, BRIDGE_SCRIPT);
if (existsSync(candidate)) return candidate;
}
}
return null;
}
// ---------------------------------------------------------------------------
// Child process line reader
// ---------------------------------------------------------------------------
function readChildLines(
stream: NodeJS.ReadableStream,
onLine: (line: string) => void,
@@ -112,28 +124,104 @@ function readChildLines(
let buffer = "";
stream.on("data", (chunk) => {
buffer += String(chunk);
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex >= 0) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (line) {
onLine(line);
}
newlineIndex = buffer.indexOf("\n");
let idx = buffer.indexOf("\n");
while (idx >= 0) {
const line = buffer.slice(0, idx).trim();
buffer = buffer.slice(idx + 1);
if (line) onLine(line);
idx = buffer.indexOf("\n");
}
});
}
// ---------------------------------------------------------------------------
// Bridge lifecycle
// ---------------------------------------------------------------------------
function handleBridgeStdoutLine(ctx: HostContext, parsed: JsonRecord) {
const type = String(parsed.type ?? "");
const sessionId =
typeof parsed.sessionId === "string" ? parsed.sessionId : "";
switch (type) {
case "ready":
ctx.bridgeReady = true;
return;
case "response": {
const requestId = String(parsed.requestId ?? "");
const pending = ctx.pendingBridge.get(requestId);
if (!pending) return;
ctx.pendingBridge.delete(requestId);
if (typeof parsed.error === "string" && parsed.error.trim()) {
pending.reject(new Error(parsed.error));
} else {
pending.resolve(parsed.response ?? null);
}
return;
}
case "chat_text":
emitChunk(ctx, sessionId, "chat_text", String(parsed.chunk ?? ""));
return;
case "tool_call_start":
emitChunk(
ctx,
sessionId,
"chat_tool_call_start",
JSON.stringify({
toolCallId: parsed.toolCallId,
toolName: parsed.toolName,
input: parsed.input,
}),
);
return;
case "tool_call_end":
emitChunk(
ctx,
sessionId,
"chat_tool_call_end",
JSON.stringify({
toolCallId: parsed.toolCallId,
toolName: parsed.toolName,
output: parsed.output,
error: parsed.error,
durationMs: parsed.durationMs,
}),
);
return;
case "error": {
const message =
typeof parsed.message === "string"
? parsed.message
: "chat runtime bridge error";
if (sessionId) {
emitChunk(
ctx,
sessionId,
"chat_core_log",
JSON.stringify({ level: "error", message }),
);
} else {
console.error("[chat-runtime-bridge]", message);
}
return;
}
}
}
export function ensureBridgeStarted(ctx: HostContext) {
if (ctx.bridgeChild && ctx.bridgeChild.exitCode === null && ctx.bridgeReady) {
return;
}
if (ctx.bridgeChild?.exitCode === null && ctx.bridgeReady) return;
const scriptPath = resolveChatRuntimeBridgeScriptPath(ctx);
if (!scriptPath) {
throw new Error("chat runtime bridge script not found");
}
if (!scriptPath) throw new Error("chat runtime bridge script not found");
ctx.bridgeReady = false;
mkdirSync(toolApprovalDir(), { recursive: true });
ctx.bridgeChild = spawn("bun", [scriptPath], {
cwd: ctx.workspaceRoot,
env: {
@@ -146,191 +234,139 @@ export function ensureBridgeStarted(ctx: HostContext) {
},
stdio: ["pipe", "pipe", "pipe"],
});
readChildLines(ctx.bridgeChild.stdout, (line) => {
const parsed = JSON.parse(line) as JsonRecord;
const type = String(parsed.type ?? "");
if (type === "ready") {
ctx.bridgeReady = true;
return;
}
if (type === "response") {
const requestId = String(parsed.requestId ?? "");
const pending = ctx.pendingBridge.get(requestId);
if (!pending) {
return;
}
ctx.pendingBridge.delete(requestId);
if (typeof parsed.error === "string" && parsed.error.trim()) {
pending.reject(new Error(parsed.error));
return;
}
pending.resolve(parsed.response ?? null);
return;
}
if (type === "chat_text") {
emitChunk(
ctx,
String(parsed.sessionId ?? ""),
"chat_text",
String(parsed.chunk ?? ""),
);
return;
}
if (type === "tool_call_start") {
emitChunk(
ctx,
String(parsed.sessionId ?? ""),
"chat_tool_call_start",
JSON.stringify({
toolCallId: parsed.toolCallId,
toolName: parsed.toolName,
input: parsed.input,
}),
);
return;
}
if (type === "tool_call_end") {
emitChunk(
ctx,
String(parsed.sessionId ?? ""),
"chat_tool_call_end",
JSON.stringify({
toolCallId: parsed.toolCallId,
toolName: parsed.toolName,
output: parsed.output,
error: parsed.error,
durationMs: parsed.durationMs,
}),
);
return;
}
if (type === "error") {
const sessionId =
typeof parsed.sessionId === "string" ? parsed.sessionId : "";
const message =
typeof parsed.message === "string"
? parsed.message
: "chat runtime bridge error";
if (sessionId) {
emitChunk(
ctx,
sessionId,
"chat_core_log",
JSON.stringify({
level: "error",
message,
}),
);
return;
}
console.error("[chat-runtime-bridge]", message);
}
handleBridgeStdoutLine(ctx, JSON.parse(line) as JsonRecord);
});
readChildLines(ctx.bridgeChild.stderr, (line) => {
console.error("[chat-runtime-bridge]", line);
});
ctx.bridgeChild.on("exit", () => {
ctx.bridgeReady = false;
ctx.bridgeChild = null;
for (const [requestId, pending] of ctx.pendingBridge.entries()) {
ctx.pendingBridge.delete(requestId);
for (const [, pending] of ctx.pendingBridge) {
pending.reject(new Error("chat runtime bridge exited"));
}
ctx.pendingBridge.clear();
});
}
// ---------------------------------------------------------------------------
// Bridge RPC
// ---------------------------------------------------------------------------
export async function runBridgeCommand(
ctx: HostContext,
command: Record<string, unknown>,
): Promise<unknown> {
ensureBridgeStarted(ctx);
const child = ctx.bridgeChild;
if (!child || !child.stdin) {
throw new Error("chat runtime bridge unavailable");
}
if (!child?.stdin) throw new Error("chat runtime bridge unavailable");
const requestId = `bridge_${ctx.bridgeRequestId++}`;
const envelope = JSON.stringify({
type: "request",
requestId,
command,
});
return await new Promise((resolve, reject) => {
const envelope = JSON.stringify({ type: "request", requestId, command });
return new Promise((resolve, reject) => {
ctx.pendingBridge.set(requestId, { resolve, reject });
child.stdin.write(`${envelope}\n`, (error) => {
if (!error) {
return;
if (error) {
ctx.pendingBridge.delete(requestId);
reject(error);
}
ctx.pendingBridge.delete(requestId);
reject(error);
});
});
}
// ---------------------------------------------------------------------------
// Tool approval helpers
// ---------------------------------------------------------------------------
function sendApprovalSnapshot(ctx: HostContext, sessionId: string) {
sendEvent(ctx, "tool_approval_state", {
sessionId,
items: listPendingToolApprovalsForSession(sessionId, 50),
});
}
export function listPendingToolApprovalsForSession(
sessionId: string,
limit = 20,
): ToolApprovalRequestItem[] {
const dir = toolApprovalDir();
if (!existsSync(dir)) {
return [];
}
const items: ToolApprovalRequestItem[] = [];
if (!existsSync(dir)) return [];
const prefix = toolApprovalRequestPrefix(sessionId);
const items: ToolApprovalRequestItem[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isFile()) {
if (
!entry.isFile() ||
!entry.name.startsWith(prefix) ||
!entry.name.endsWith(".json")
)
continue;
}
if (!entry.name.startsWith(prefix) || !entry.name.endsWith(".json")) {
continue;
}
try {
const parsed = JSON.parse(
readFileSync(join(dir, entry.name), "utf8"),
) as ToolApprovalRequestItem;
items.push(parsed);
items.push(
JSON.parse(
readFileSync(join(dir, entry.name), "utf8"),
) as ToolApprovalRequestItem,
);
} catch {
// Ignore malformed approval files.
}
}
items.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
items.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
return items.slice(0, Math.max(1, limit));
}
export function broadcastApprovalSnapshots(ctx: HostContext) {
const dir = toolApprovalDir();
if (!existsSync(dir)) {
return;
}
if (!existsSync(dir)) return;
const sessionIds = new Set<string>();
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.includes(".request.")) {
continue;
}
const [sessionId] = entry.name.split(".request.");
if (sessionId?.trim()) {
sessionIds.add(sessionId.trim());
}
if (!entry.isFile() || !entry.name.includes(".request.")) continue;
const id = entry.name.split(".request.")[0]?.trim();
if (id) sessionIds.add(id);
}
for (const sessionId of sessionIds) {
sendEvent(ctx, "tool_approval_state", {
sessionId,
items: listPendingToolApprovalsForSession(sessionId, 50),
});
sendApprovalSnapshot(ctx, sessionId);
}
}
function makeQueuedTurnId(): string {
return `queued_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
function getPromptsInQueue(session: LiveSession): PromptInQueue[] {
return session.pendingTurns.map((turn) => ({
id: turn.id,
prompt: turn.prompt,
steer: turn.steer,
}));
}
function sendPromptsInQueueSnapshot(ctx: HostContext, sessionId: string) {
const session = ctx.liveSessions.get(sessionId);
sendEvent(ctx, "prompts_in_queue_state", {
sessionId,
items: session ? getPromptsInQueue(session) : [],
});
}
export function ensureApprovalWatcher(ctx: HostContext) {
if (ctx.approvalWatcher) {
return;
}
if (ctx.approvalWatcher) return;
mkdirSync(toolApprovalDir(), { recursive: true });
ctx.approvalWatcher = watch(toolApprovalDir(), () => {
if (ctx.approvalBroadcastTimer) {
clearTimeout(ctx.approvalBroadcastTimer);
}
ctx.approvalBroadcastTimer = setTimeout(() => {
broadcastApprovalSnapshots(ctx);
}, 50);
if (ctx.approvalBroadcastTimer) clearTimeout(ctx.approvalBroadcastTimer);
ctx.approvalBroadcastTimer = setTimeout(
() => broadcastApprovalSnapshots(ctx),
50,
);
});
}
@@ -340,9 +376,9 @@ export async function respondToolApproval(
) {
const sessionId = String(args?.sessionId ?? "").trim();
const requestId = String(args?.requestId ?? "").trim();
if (!sessionId || !requestId) {
if (!sessionId || !requestId)
throw new Error("sessionId and requestId are required");
}
const path = toolApprovalDecisionPath(sessionId, requestId);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(
@@ -353,166 +389,317 @@ export async function respondToolApproval(
ts: nowMs(),
}),
);
const requestPath = join(
toolApprovalDir(),
`${sessionId}.request.${requestId}.json`,
);
if (existsSync(requestPath)) {
unlinkSync(requestPath);
}
sendEvent(ctx, "tool_approval_state", {
sessionId,
items: listPendingToolApprovalsForSession(sessionId, 50),
});
if (existsSync(requestPath)) unlinkSync(requestPath);
sendApprovalSnapshot(ctx, sessionId);
return true;
}
export async function handleChatSessionCommand(
// ---------------------------------------------------------------------------
// Chat turn execution
// ---------------------------------------------------------------------------
async function executeChatTurn(
ctx: HostContext,
request: ChatSessionCommandRequest,
): Promise<unknown> {
if (request.action === "start") {
if (!request.config) {
throw new Error("missing config for start action");
}
setRuntimeHomeDir(request.config);
addRuntimeLoggerContext(request.config);
const response = (await runBridgeCommand(ctx, {
action: "start",
config: request.config,
})) as { sessionId?: string };
const sessionId = response.sessionId?.trim();
if (!sessionId) {
throw new Error("chat runtime bridge start response missing session id");
}
await runBridgeCommand(ctx, {
action: "set_sessions",
sessionIds: [sessionId],
});
ctx.liveSessions.set(sessionId, {
config: request.config,
messages: [],
busy: false,
startedAt: nowMs(),
status: "idle",
});
return { sessionId };
}
sessionId: string,
session: LiveSession,
turn: QueuedChatTurn,
): Promise<ChatTurnResult> {
if (turn.config) session.config = turn.config;
if (turn.prompt) session.prompt = turn.prompt;
if (request.action === "send") {
const prompt = request.prompt?.trim() || "";
const hasAttachments =
(request.attachments?.userImages?.length ?? 0) > 0 ||
(request.attachments?.userFiles?.length ?? 0) > 0;
if (!prompt && !hasAttachments) {
throw new Error("prompt is required for send action");
}
const sessionId = request.sessionId?.trim();
if (!sessionId) {
throw new Error("sessionId is required for send action");
}
session.busy = true;
session.status = "running";
session.endedAt = undefined;
let session = ctx.liveSessions.get(sessionId);
if (!session) {
if (!request.config) {
throw new Error("session not found. start a new session.");
}
const messages = readPersistedChatMessages(sessionId);
if (!messages) {
throw new Error("session not found. start a new session.");
}
session = {
config: request.config,
messages,
busy: false,
startedAt: nowMs(),
status: "idle",
prompt: derivePromptFromMessages(messages),
title: readSessionMetadataTitle(sessionId),
};
ctx.liveSessions.set(sessionId, session);
}
if (request.config) {
session.config = request.config;
}
if (session.busy) {
throw new Error("session is busy. wait for current response to finish.");
}
session.busy = true;
session.status = "running";
session.endedAt = undefined;
if (prompt) {
session.prompt = prompt;
}
setRuntimeHomeDir(session.config);
addRuntimeLoggerContext(session.config);
await runBridgeCommand(ctx, {
action: "set_sessions",
sessionIds: [sessionId],
});
setRuntimeHomeDir(session.config);
addRuntimeLoggerContext(session.config);
await runBridgeCommand(ctx, {
action: "set_sessions",
sessionIds: [sessionId],
});
try {
const resultEnvelope = (await runBridgeCommand(ctx, {
action: "send",
sessionId,
request: {
config: session.config,
messages: session.messages,
prompt,
attachments: request.attachments,
prompt: turn.prompt,
attachments: turn.attachments,
},
})) as { result?: ChatTurnResult };
const result = resultEnvelope.result;
if (!result) {
throw new Error("chat runtime bridge send response missing result");
}
const persistedMessages = persistUsageInMessages(
const result = resultEnvelope.result;
if (!result)
throw new Error("chat runtime bridge send response missing result");
session.messages = persistUsageInMessages(
(Array.isArray(result.messages) ? result.messages : []) as unknown[],
session.config,
result,
);
session.messages = persistedMessages;
session.busy = false;
session.status = normalizeChatFinishStatus(result.finishReason);
session.endedAt = nowMs();
persistSessionMessages(sessionId, persistedMessages);
sendEvent(ctx, "tool_approval_state", {
sessionId,
items: listPendingToolApprovalsForSession(sessionId, 50),
});
return {
sessionId,
result,
};
}
if (request.action === "abort") {
const sessionId = request.sessionId?.trim();
if (sessionId) {
await runBridgeCommand(ctx, { action: "abort", sessionId });
const session = ctx.liveSessions.get(sessionId);
if (session) {
session.busy = false;
session.status = "cancelled";
session.endedAt = nowMs();
}
}
return {
sessionId: request.sessionId,
ok: true,
};
}
persistSessionMessages(sessionId, session.messages);
sendApprovalSnapshot(ctx, sessionId);
if (request.action === "reset") {
const sessionId = request.sessionId?.trim();
if (sessionId) {
ctx.liveSessions.delete(sessionId);
await runBridgeCommand(ctx, { action: "reset", sessionId });
}
return {
sessionId: request.sessionId,
ok: true,
};
return result;
} finally {
session.busy = false;
}
}
throw new Error("unsupported action");
async function drainSessionQueue(
ctx: HostContext,
sessionId: string,
): Promise<void> {
const session = ctx.liveSessions.get(sessionId);
if (!session || session.busy) return;
const nextTurn = session.pendingTurns.shift();
if (!nextTurn) return;
sendPromptsInQueueSnapshot(ctx, sessionId);
emitChunk(
ctx,
sessionId,
"chat_queued_prompt_start",
JSON.stringify({
prompt: nextTurn.prompt,
attachmentCount:
(nextTurn.attachments?.userImages?.length ?? 0) +
(nextTurn.attachments?.userFiles?.length ?? 0),
}),
);
try {
await executeChatTurn(ctx, sessionId, session, nextTurn);
} catch (error) {
session.status = "error";
session.endedAt = nowMs();
emitChunk(
ctx,
sessionId,
"chat_core_log",
JSON.stringify({
level: "error",
message: error instanceof Error ? error.message : String(error),
}),
);
}
if (session.pendingTurns.length > 0) {
void drainSessionQueue(ctx, sessionId);
}
}
// ---------------------------------------------------------------------------
// Command handler
// ---------------------------------------------------------------------------
function createLiveSession(
config: JsonRecord,
extra?: Partial<LiveSession>,
): LiveSession {
return {
config,
messages: [],
pendingTurns: [],
busy: false,
startedAt: nowMs(),
status: "idle",
...extra,
};
}
async function handleStart(
ctx: HostContext,
request: ChatSessionCommandRequest,
) {
if (!request.config) throw new Error("missing config for start action");
setRuntimeHomeDir(request.config);
addRuntimeLoggerContext(request.config);
const response = (await runBridgeCommand(ctx, {
action: "start",
config: request.config,
})) as { sessionId?: string };
const sessionId = response.sessionId?.trim();
if (!sessionId)
throw new Error("chat runtime bridge start response missing session id");
await runBridgeCommand(ctx, {
action: "set_sessions",
sessionIds: [sessionId],
});
ctx.liveSessions.set(sessionId, createLiveSession(request.config));
return { sessionId };
}
async function handleSend(
ctx: HostContext,
request: ChatSessionCommandRequest,
) {
const prompt = request.prompt?.trim() || "";
const hasAttachments =
(request.attachments?.userImages?.length ?? 0) > 0 ||
(request.attachments?.userFiles?.length ?? 0) > 0;
if (!prompt && !hasAttachments)
throw new Error("prompt is required for send action");
const sessionId = request.sessionId?.trim();
if (!sessionId) throw new Error("sessionId is required for send action");
let session = ctx.liveSessions.get(sessionId);
if (!session) {
if (!request.config)
throw new Error("session not found. start a new session.");
const messages = readPersistedChatMessages(sessionId);
if (!messages) throw new Error("session not found. start a new session.");
session = createLiveSession(request.config, {
messages,
prompt: derivePromptFromMessages(messages),
title: readSessionMetadataTitle(sessionId),
});
ctx.liveSessions.set(sessionId, session);
}
if (request.config) session.config = request.config;
const turn: QueuedChatTurn = {
id: makeQueuedTurnId(),
prompt,
steer: false,
config: request.config,
attachments: request.attachments,
};
if (session.busy) {
session.pendingTurns.push(turn);
sendPromptsInQueueSnapshot(ctx, sessionId);
return {
sessionId,
ok: true,
queued: true,
promptsInQueue: getPromptsInQueue(session),
};
}
const result = await executeChatTurn(ctx, sessionId, session, turn);
if (session.pendingTurns.length > 0) {
sendPromptsInQueueSnapshot(ctx, sessionId);
void drainSessionQueue(ctx, sessionId);
}
return {
sessionId,
result,
queued: false,
promptsInQueue: getPromptsInQueue(session),
};
}
async function handleAbort(
ctx: HostContext,
request: ChatSessionCommandRequest,
) {
const sessionId = request.sessionId?.trim();
if (sessionId) {
await runBridgeCommand(ctx, { action: "abort", sessionId });
const session = ctx.liveSessions.get(sessionId);
if (session) {
session.busy = false;
session.pendingTurns = [];
session.status = "cancelled";
session.endedAt = nowMs();
}
sendPromptsInQueueSnapshot(ctx, sessionId);
}
return { sessionId: request.sessionId, ok: true };
}
async function handleReset(
ctx: HostContext,
request: ChatSessionCommandRequest,
) {
const sessionId = request.sessionId?.trim();
if (sessionId) {
ctx.liveSessions.delete(sessionId);
await runBridgeCommand(ctx, { action: "reset", sessionId });
sendPromptsInQueueSnapshot(ctx, sessionId);
}
return { sessionId: request.sessionId, ok: true };
}
async function handlePendingPrompts(
ctx: HostContext,
request: ChatSessionCommandRequest,
) {
const sessionId = request.sessionId?.trim();
if (!sessionId) throw new Error("sessionId is required");
const session = ctx.liveSessions.get(sessionId);
return {
sessionId,
promptsInQueue: session ? getPromptsInQueue(session) : [],
};
}
async function handleSteerPrompt(
ctx: HostContext,
request: ChatSessionCommandRequest,
) {
const sessionId = request.sessionId?.trim();
const promptId = request.promptId?.trim();
if (!sessionId || !promptId) {
throw new Error("sessionId and promptId are required");
}
const session = ctx.liveSessions.get(sessionId);
if (!session) {
return { sessionId, promptsInQueue: [] };
}
const existingIndex = session.pendingTurns.findIndex(
(turn) => turn.id === promptId,
);
if (existingIndex >= 0) {
const [turn] = session.pendingTurns.splice(existingIndex, 1);
session.pendingTurns.unshift({ ...turn, steer: true });
}
sendPromptsInQueueSnapshot(ctx, sessionId);
return {
sessionId,
promptsInQueue: getPromptsInQueue(session),
};
}
const ACTION_HANDLERS: Record<
string,
(ctx: HostContext, req: ChatSessionCommandRequest) => Promise<unknown>
> = {
start: handleStart,
send: handleSend,
abort: handleAbort,
reset: handleReset,
pending_prompts: handlePendingPrompts,
steer_prompt: handleSteerPrompt,
};
export async function handleChatSessionCommand(
ctx: HostContext,
request: ChatSessionCommandRequest,
): Promise<unknown> {
const handler = ACTION_HANDLERS[request.action];
if (!handler) throw new Error("unsupported action");
return handler(ctx, request);
}
+23 -1
View File
@@ -28,16 +28,38 @@ export type ChatTurnResult = RpcChatTurnResult & {
};
export type ChatSessionCommandRequest = {
action: "start" | "send" | "abort" | "reset";
action:
| "start"
| "send"
| "abort"
| "reset"
| "pending_prompts"
| "steer_prompt";
sessionId?: string;
prompt?: string;
promptId?: string;
config?: JsonRecord;
attachments?: ChatTurnAttachments;
};
export type QueuedChatTurn = {
id: string;
prompt: string;
steer: boolean;
config?: JsonRecord;
attachments?: ChatTurnAttachments;
};
export type PromptInQueue = {
id: string;
prompt: string;
steer: boolean;
};
export type LiveSession = {
config: JsonRecord;
messages: unknown[];
pendingTurns: QueuedChatTurn[];
busy: boolean;
startedAt: number;
endedAt?: number;
+182 -36
View File
@@ -260,6 +260,7 @@ struct ChatSessionCommandResponse {
session_id: Option<String>,
result: Option<ChatTurnResult>,
ok: Option<bool>,
queued: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -349,6 +350,7 @@ impl ChatWsBridgeState {
struct ChatRuntimeSession {
config: StartSessionRequest,
messages: Vec<Value>,
pending_turns: Vec<ChatRunTurnRequest>,
busy: bool,
started_at: u64,
ended_at: Option<u64>,
@@ -1369,9 +1371,7 @@ fn extract_message_notice_meta(message: &Value) -> Option<HydratedChatMessageMet
Some(HydratedChatMessageMeta {
tool_name: None,
hook_event_name: message_kind
.as_ref()
.map(|_| "history_notice".to_string()),
hook_event_name: message_kind.as_ref().map(|_| "history_notice".to_string()),
message_kind,
display_role,
reason,
@@ -1894,10 +1894,7 @@ fn resolve_desktop_backend_binary_path(context: &AppContext) -> Option<PathBuf>
.map(|parent| parent.join("Resources").join("code-host"))
}),
];
candidates
.into_iter()
.flatten()
.find(|path| path.exists())
candidates.into_iter().flatten().find(|path| path.exists())
}
fn ensure_desktop_backend_started(
@@ -3012,6 +3009,158 @@ fn run_chat_turn_via_rpc_runtime(
.map_err(|e| format!("invalid chat runtime bridge result: {e}"))
}
fn persist_chat_turn_result(
state: &Arc<ChatSessionStore>,
session_id: &str,
config: &StartSessionRequest,
result: &ChatTurnResult,
) -> Result<(), String> {
let mut sessions = state
.sessions
.lock()
.map_err(|_| "failed to lock chat session store")?;
if let Some(session) = sessions.get_mut(session_id) {
let persisted_messages = persist_usage_in_messages(&result.messages, config, result);
session.messages = persisted_messages.clone();
session.status = normalize_chat_finish_status(result.finish_reason.as_deref());
session.ended_at = Some(now_ms());
if let Some(path) = shared_session_messages_write_path(session_id) {
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let body = serde_json::json!({
"messages": persisted_messages,
"ts": now_ms(),
});
if let Ok(encoded) = serde_json::to_vec(&body) {
let _ = fs::write(path, encoded);
}
}
}
Ok(())
}
fn mark_chat_turn_failed(state: &Arc<ChatSessionStore>, session_id: &str) -> Result<(), String> {
let mut sessions = state
.sessions
.lock()
.map_err(|_| "failed to lock chat session store")?;
if let Some(session) = sessions.get_mut(session_id) {
session.status = "failed".to_string();
session.ended_at = Some(now_ms());
}
Ok(())
}
fn dequeue_next_chat_turn(
state: &Arc<ChatSessionStore>,
session_id: &str,
) -> Result<Option<(StartSessionRequest, ChatRunTurnRequest)>, String> {
let mut sessions = state
.sessions
.lock()
.map_err(|_| "failed to lock chat session store")?;
let Some(session) = sessions.get_mut(session_id) else {
return Ok(None);
};
if session.pending_turns.is_empty() {
session.busy = false;
return Ok(None);
}
let mut next = session.pending_turns.remove(0);
session.busy = true;
session.status = "running".to_string();
session.ended_at = None;
if !next.prompt.trim().is_empty() {
session.prompt = Some(next.prompt.clone());
}
next.config = session.config.clone();
next.messages = session.messages.clone();
Ok(Some((session.config.clone(), next)))
}
fn queue_chat_turn(
state: &Arc<ChatSessionStore>,
session_id: &str,
turn_request: ChatRunTurnRequest,
) -> Result<(), String> {
let mut sessions = state
.sessions
.lock()
.map_err(|_| "failed to lock chat session store")?;
let session = sessions
.get_mut(session_id)
.ok_or_else(|| "session not found. start a new session.".to_string())?;
session.pending_turns.push(turn_request);
Ok(())
}
fn spawn_next_queued_chat_turn(
app: &AppHandle,
state: &Arc<ChatSessionStore>,
context: &AppContext,
session_id: String,
) {
let next = match dequeue_next_chat_turn(state, &session_id) {
Ok(value) => value,
Err(error) => {
eprintln!("[chat-queue] failed to dequeue next turn for {session_id}: {error}");
return;
}
};
let Some((config, turn_request)) = next else {
return;
};
let app_for_turn = app.clone();
let state_for_turn = state.clone();
let context_for_turn = context.clone();
tauri::async_runtime::spawn(async move {
let turn_result = tauri::async_runtime::spawn_blocking({
let app_for_run = app_for_turn.clone();
let state_for_run = state_for_turn.clone();
let context_for_run = context_for_turn.clone();
let session_id_for_run = session_id.clone();
let request_for_run = turn_request.clone();
move || {
run_chat_turn_via_rpc_runtime(
&app_for_run,
&state_for_run,
&context_for_run,
&session_id_for_run,
&request_for_run,
)
}
})
.await
.map_err(|e| format!("chat turn task failed: {e}"));
match turn_result {
Ok(Ok(result)) => {
if let Err(error) =
persist_chat_turn_result(&state_for_turn, &session_id, &config, &result)
{
eprintln!(
"[chat-queue] failed to persist turn result for {session_id}: {error}"
);
}
}
_ => {
if let Err(error) = mark_chat_turn_failed(&state_for_turn, &session_id) {
eprintln!("[chat-queue] failed to mark turn failed for {session_id}: {error}");
}
}
}
spawn_next_queued_chat_turn(
&app_for_turn,
&state_for_turn,
&context_for_turn,
session_id,
);
});
}
fn abort_chat_session_via_rpc_runtime(
app: &AppHandle,
state: &Arc<ChatSessionStore>,
@@ -4116,6 +4265,7 @@ async fn handle_chat_session_command(
ChatRuntimeSession {
config,
messages: Vec::new(),
pending_turns: Vec::new(),
busy: false,
started_at: now_ms(),
ended_at: None,
@@ -4128,6 +4278,7 @@ async fn handle_chat_session_command(
session_id: Some(session_id),
result: None,
ok: None,
queued: None,
})
}
"send" => {
@@ -4171,6 +4322,7 @@ async fn handle_chat_session_command(
prompt: derive_prompt_from_messages(&messages),
title: read_session_metadata_title(&session_id),
messages,
pending_turns: Vec::new(),
busy: false,
started_at: now_ms(),
ended_at: None,
@@ -4192,7 +4344,20 @@ async fn handle_chat_session_command(
session.config = next_config;
}
if session.busy {
return Err("session is busy. wait for current response to finish.".to_string());
let queued_request = ChatRunTurnRequest {
config: session.config.clone(),
messages: Vec::new(),
prompt: prompt.clone(),
attachments: attachments.clone(),
};
drop(sessions);
queue_chat_turn(state, &session_id, queued_request)?;
return Ok(ChatSessionCommandResponse {
session_id: Some(session_id),
result: None,
ok: Some(true),
queued: Some(true),
});
}
session.busy = true;
session.status = "running".to_string();
@@ -4228,35 +4393,12 @@ async fn handle_chat_session_command(
.await
.map_err(|e| format!("chat turn task failed: {e}"));
let mut sessions = state
.sessions
.lock()
.map_err(|_| "failed to lock chat session store")?;
if let Some(session) = sessions.get_mut(&session_id) {
session.busy = false;
if let Ok(Ok(result)) = &turn_result {
let persisted_messages =
persist_usage_in_messages(&result.messages, &config, result);
session.messages = persisted_messages.clone();
session.status = normalize_chat_finish_status(result.finish_reason.as_deref());
session.ended_at = Some(now_ms());
if let Some(path) = shared_session_messages_write_path(&session_id) {
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let body = serde_json::json!({
"messages": persisted_messages,
"ts": now_ms(),
});
if let Ok(encoded) = serde_json::to_vec(&body) {
let _ = fs::write(path, encoded);
}
}
} else {
session.status = "failed".to_string();
session.ended_at = Some(now_ms());
}
if let Ok(Ok(result)) = &turn_result {
persist_chat_turn_result(state, &session_id, &config, result)?;
} else {
mark_chat_turn_failed(state, &session_id)?;
}
spawn_next_queued_chat_turn(app, state, context, session_id.clone());
let turn_result = turn_result?;
let result = turn_result?;
@@ -4264,6 +4406,7 @@ async fn handle_chat_session_command(
session_id: Some(session_id),
result: Some(result),
ok: None,
queued: Some(false),
})
}
"abort" => Ok(ChatSessionCommandResponse {
@@ -4276,6 +4419,7 @@ async fn handle_chat_session_command(
.map_err(|_| "failed to lock chat session store")?;
if let Some(session) = sessions.get_mut(&session_id) {
session.busy = false;
session.pending_turns.clear();
session.status = "cancelled".to_string();
session.ended_at = Some(now_ms());
}
@@ -4284,6 +4428,7 @@ async fn handle_chat_session_command(
},
result: None,
ok: Some(true),
queued: None,
}),
"reset" => {
if let Some(session_id) = request.session_id.clone() {
@@ -4304,6 +4449,7 @@ async fn handle_chat_session_command(
session_id: request.session_id,
result: None,
ok: Some(true),
queued: None,
})
}
_ => Err("unsupported action".to_string()),