diff --git a/.changeset/session-goals.md b/.changeset/session-goals.md new file mode 100644 index 00000000000..b4984aa46e0 --- /dev/null +++ b/.changeset/session-goals.md @@ -0,0 +1,15 @@ +--- +"@kilocode/cli": minor +"kilo-code": minor +"@kilocode/kilo-ui": patch +--- + +Keep working toward a session goal with `/goal`, with shared pause, resume, and clear controls in the terminal and VS Code. Pause goals after no-action replies, terminal failures, Stop, new messages, and backend restarts. Rename custom commands or MCP prompts named `goal` to use this reserved command. Show a labeled Goal icon with hover details while work runs. + +Compose multiline goals with images and file attachments in VS Code. Select `/goal` to enter goal mode, or cancel to keep the draft as ordinary chat. Keep drafts and attachments when submission fails. + +Keep the current goal running when replacement attachments are invalid. Make pending Goal submissions read-only, and preserve the draft when Cancel exits Goal mode before acknowledgement. + +Disable clarification questions during active goals and delegated work while keeping permission approvals unchanged. Make safe, reversible decisions autonomously and report completion or blockers. + +Retain Active, Complete, Blocked, and Paused goals with their objective and reason until explicitly cleared. Let the working model explicitly report completion or a blocker with the Goal-only reporting tool, without a separate evaluator or independent verification claim. Pause no-action turns that have no explicit report. Keep complete goals complete after a backend restart and label their resume action as Restart. diff --git a/bun.lock b/bun.lock index 22424f84c39..6d9d5cf76e5 100644 --- a/bun.lock +++ b/bun.lock @@ -1004,25 +1004,25 @@ }, }, "trustedDependencies": [ - "esbuild", - "protobufjs", "web-tree-sitter", + "esbuild", "tree-sitter-bash", + "protobufjs", ], "patchedDependencies": { - "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", - "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", + "virtua@0.49.1": "patches/virtua@0.49.1.patch", + "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", - "pacote@21.5.1": "patches/pacote@21.5.1.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", + "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", + "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "pacote@21.5.1": "patches/pacote@21.5.1.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", - "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "solid-js@1.9.12": "patches/solid-js@1.9.12.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", - "virtua@0.49.1": "patches/virtua@0.49.1.patch", - "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", + "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", }, "overrides": { "@effect/platform-node-shared": "4.0.0-beta.74", diff --git a/packages/kilo-ui/src/components/icon.tsx b/packages/kilo-ui/src/components/icon.tsx index 7aaf1011029..c50ba12ea2b 100644 --- a/packages/kilo-ui/src/components/icon.tsx +++ b/packages/kilo-ui/src/components/icon.tsx @@ -79,6 +79,10 @@ const icons: Record = { viewBox: "0 0 24 24", path: ``, }, + target: { + viewBox: "0 0 20 20", + path: ``, + }, local: { viewBox: "0 0 20 20", path: ``, diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 5a2711477af..ad547e8ecbc 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -140,7 +140,7 @@ import { nativeTitle } from "./kilo-provider/native-tab-title" import { isActivity, type Activity } from "../webview-ui/src/utils/session-activity" import type { PRReviewCommentData, ReviewMessageData } from "./shared/review-comments" import { feedbackMetadata, parseFeedback, type BrowserFeedbackData } from "./shared/browser-feedback" -import { completesWithoutStatus } from "./kilo-provider/command-completion" +import { completesWithoutStatus, goalControl } from "./kilo-provider/command-completion" import { KiloProviderMemory } from "./kilo-provider/memory" import { @@ -4278,7 +4278,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ) resolved = await this.resolveSession(sessionID, draftID, context, contextDirectory) if (!resolved) return - if (sandbox) await sandbox + const control = goalControl(command, args) + const stopping = control && args.trim() !== "" + if (sandbox && !stopping) await sandbox const sid = resolved.sid const dir = resolved.dir @@ -4294,26 +4296,31 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper source: f.source, })) - await this.checkpoints.get(sid) - await runWithMessageConfirmation(this.confirmations, messageID, "KiloProvider: Command request", () => - this.withRetry( - () => - this.client!.session.command({ - sessionID: sid, - directory: dir, - command, - arguments: args, - messageID, - model: providerID && modelID ? `${providerID}/${modelID}` : undefined, - agent, - variant, - parts, - snapshotInitialization: this.opts.snapshotInitialization, - }), - sid, + if (!control) await this.checkpoints.get(sid) + const send = () => + this.client!.session.command({ + sessionID: sid, + directory: dir, + command, + arguments: args, messageID, - ), - ) + model: !control && providerID && modelID ? `${providerID}/${modelID}` : undefined, + agent: control ? undefined : agent, + variant: control ? undefined : variant, + parts, + snapshotInitialization: this.opts.snapshotInitialization, + }) + await runWithMessageConfirmation(this.confirmations, messageID, "KiloProvider: Command request", async () => { + if (command !== "goal") return this.withRetry(send, sid, messageID) + const result = await send() + if (result.error) throw result.error + if (args.trim()) return + const message = result.data?.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n") + if (message) void vscode.window.showInformationMessage(message) + }) if (messageID && completesWithoutStatus(command)) { this.postMessage({ type: "sessionCommandCompleted", messageID }) } @@ -4528,6 +4535,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper trackedSessionIds: this.trackedSessionIds, connectionService: this.connectionService, postMessage: (msg) => this.postMessage(msg), + notify: (message) => void vscode.window.showInformationMessage(message), getWorkspaceDirectory: (sid) => this.getWorkspaceDirectory(sid), gatherEditorContext: () => this.gatherEditorContext(), runWithMessageConfirmation: (id, label, run) => runWithMessageConfirmation(this.confirmations, id, label, run), diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index ad652cc2ea5..0c31cdd74c5 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -194,7 +194,10 @@ export async function runWithMessageConfirmation( } } -export function sessionToWebview(session: Pick) { +export function sessionToWebview( + session: Pick, +) { + const goal = session.metadata?.["kilo.goal"] return { id: session.id, parentID: session.parentID ?? null, @@ -206,6 +209,26 @@ export function sessionToWebview(session: Pick runWithMessageConfirmation?( @@ -195,7 +196,7 @@ export async function handleImportAndSend( filename: f.filename, source: f.source, })) - await client.session.command( + const result = await client.session.command( { sessionID: session.id, directory: dir, @@ -209,6 +210,13 @@ export async function handleImportAndSend( }, { throwOnError: true }, ) + if (command === "goal" && !commandArgs?.trim()) { + const message = result.data.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n") + if (message) ctx.notify?.(message) + } return } diff --git a/packages/kilo-vscode/src/services/attention/service.ts b/packages/kilo-vscode/src/services/attention/service.ts index e70c1310dcf..f60004bac9f 100644 --- a/packages/kilo-vscode/src/services/attention/service.ts +++ b/packages/kilo-vscode/src/services/attention/service.ts @@ -22,6 +22,7 @@ export function previewSound(value: string) { export class AttentionService implements vscode.Disposable { private readonly active = new Set() + private readonly goals = new Set() private readonly errored = new Set() private readonly questions = new Set() private readonly permissions = new Set() @@ -59,12 +60,20 @@ export class AttentionService implements vscode.Disposable { } private sync(event: Sync) { - if (event.name !== "session.deleted.1") return - this.remove(event.data.sessionID) + if (event.name === "session.deleted.1") return this.remove(event.data.sessionID) + if (event.name !== "session.updated.1" && event.name !== "session.created.1") return + if (!("metadata" in event.data.info)) return + const goal = event.data.info.metadata?.["kilo.goal"] + if (goal && typeof goal === "object" && "active" in goal && goal.active === true) { + this.goals.add(event.data.sessionID) + return + } + this.goals.delete(event.data.sessionID) } private remove(sessionID: string) { this.active.delete(sessionID) + this.goals.delete(sessionID) this.errored.delete(sessionID) } @@ -110,7 +119,7 @@ export class AttentionService implements vscode.Disposable { if (!this.active.delete(sessionID)) return if (this.errored.delete(sessionID)) return if (event.properties.reason !== "completed") return - if (event.properties.parentID !== undefined) return + if (event.properties.parentID !== undefined || this.goals.has(sessionID)) return this.notify("done") } @@ -131,6 +140,7 @@ export class AttentionService implements vscode.Disposable { private reset() { this.active.clear() + this.goals.clear() this.errored.clear() this.questions.clear() this.permissions.clear() diff --git a/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx index b1326cee70f..10d29b96f9b 100644 --- a/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx +++ b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx @@ -33,7 +33,6 @@ Object.assign(globalThis, { MutationObserver: window.MutationObserver, IntersectionObserver: window.IntersectionObserver, ResizeObserver: window.ResizeObserver, - IntersectionObserver: window.IntersectionObserver, CustomEvent: window.CustomEvent, customElements: window.customElements, Event: window.Event, @@ -67,7 +66,9 @@ const { PromptInput } = await import("../../webview-ui/src/components/chat/Promp const { IndexingProvider } = await import("../../webview-ui/src/context/indexing") const { MemoryProvider } = await import("../../webview-ui/src/context/memory") const { SpeechToTextModelsProvider } = await import("../../webview-ui/src/context/speech-to-text-models") -const { drafts, imageDrafts, savePromptDraft } = await import("../../webview-ui/src/utils/draft-store") +const { drafts, imageDrafts, reviewDrafts, browserDrafts, savePromptDraft } = await import( + "../../webview-ui/src/utils/draft-store" +) const [settings, setSettings] = createSignal<{ model?: string @@ -888,6 +889,42 @@ try { assert.equal(value.currentSessionID(), "ses_command-promoted") value.setDraftSessionID(undefined) + { + value.clearCurrentSession() + value.selectAgent("ask") + assert.equal(value.selectedAgent(), "ask") + choice(value.selected(), recommended) + const usage = JSON.stringify(value.modelUsageHistory()) + const recent = JSON.stringify(value.recentModels()) + const start = sent.length + assert.equal(value.sendCommand("goal", ""), true) + const command = sent.at(-1) + assert(command?.type === "sendCommand") + assert(command.draftID) + assert.equal(command.sessionID, undefined) + assert.equal(command.providerID, undefined) + assert.equal(command.modelID, undefined) + assert.equal(command.agent, undefined) + assert.equal(command.variant, undefined) + assert.deepEqual( + sent.slice(start).map((message) => message.type), + ["sendCommand"], + ) + await emit({ type: "sessionCreated", session: info("ses_goal-draft"), draftID: command.draftID }) + assert.equal(value.currentSessionID(), "ses_goal-draft") + assert.equal(value.selectedAgent(), "ask") + choice(value.selected(), recommended) + await emit({ type: "sessionCommandCompleted", messageID: command.messageID }) + assert.equal(value.submitting(), false) + assert.equal(JSON.stringify(value.modelUsageHistory()), usage) + assert.equal(JSON.stringify(value.recentModels()), recent) + assert.equal( + sent.slice(start).some((message) => message.type === "recordModelUsage"), + false, + ) + value.setDraftSessionID(undefined) + } + const key = "acceptance:session:composer" const image = { id: "image", filename: "image.png", mime: "image/png", dataUrl: "data:image/png;base64,cGl4ZWw=" } const input = () => { @@ -895,12 +932,12 @@ try { assert(element) return element } - const seed = async (text: string) => { + const seed = async (text: string, sid = "composer") => { setComposer(false) await settle() - value.setCurrentSessionID("composer") - await emit({ type: "sessionStatus", sessionID: "composer", status: "idle" }) - savePromptDraft(key, text, [], [image]) + value.setCurrentSessionID(sid) + await emit({ type: "sessionStatus", sessionID: sid, status: "idle" }) + savePromptDraft(`acceptance:session:${sid}`, text, [], [image]) setComposer(true) await settle() await emit({ @@ -917,7 +954,9 @@ try { input().dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true })) return } - const button = host.querySelector('[aria-label="prompt.action.send"]') + const button = host.querySelector( + '[aria-label="prompt.action.send"], [aria-label="prompt.goal.start"]', + ) assert(button) button.click() } @@ -969,6 +1008,187 @@ try { await emit({ type: "terminalContextResult", requestId: request.requestId, content: "terminal output" }) retained(text, count) } + await catalog("org-a", [recommended.modelID], recommended.modelID) + for (const sid of ["composer", "cloud:preview"]) { + await seed("/goal", sid) + submit(false) + await settle() + assert(host.querySelector(".prompt-goal-header")) + const text = "Fix the failing tests" + input().value = text + input().dispatchEvent(new window.Event("input", { bubbles: true })) + await settle() + const scope = `acceptance:session:${sid}` + for (const success of [false, true]) { + const button = host.querySelector('[aria-label="prompt.goal.start"]') + assert(button) + assert.equal(button.getAttribute("aria-disabled"), "false") + const count = requests().length + const messages = value.messages().length + button.click() + await settle() + assert.equal(requests().length, count + 1) + const request = requests().at(-1) + assert(request?.type === (sid.startsWith("cloud:") ? "importAndSend" : "sendCommand")) + assert.equal(request.command, "goal") + assert.equal(request.type === "sendCommand" ? request.arguments : request.commandArgs, `-- ${text}`) + assert.equal(request.modelID, recommended.modelID) + assert.deepEqual(request.files, [{ mime: image.mime, url: image.dataUrl, filename: image.filename }]) + assert.equal(value.messages().length, messages, "Goal sends must not add optimistic chat messages") + assert.equal(input().value, text, "Keep the Goal draft until its acknowledgement") + assert.equal(button.getAttribute("aria-disabled"), "true") + assert.equal(input().readOnly, true) + assert.equal(input().getAttribute("aria-disabled"), "true") + input().value = "Rejected pending edit" + input().dispatchEvent(new window.Event("input", { bubbles: true })) + for (const key of ["ArrowUp", "ArrowDown", "Backspace", "Tab", "Enter"]) { + input().setSelectionRange(0, 0) + input().dispatchEvent(new window.KeyboardEvent("keydown", { key, bubbles: true })) + } + const clipboard = new window.DataTransfer() + clipboard.items.add(new window.File(["image"], "extra.png", { type: "image/png" })) + const paste = new window.ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: clipboard }) + input().dispatchEvent(paste) + assert.equal(paste.defaultPrevented, true) + const transfer = new window.DataTransfer() + transfer.setData("application/vnd.code.uri-list", "file:///test/extra.txt") + const drop = new window.DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer }) + input().dispatchEvent(drop) + assert.equal(drop.defaultPrevented, true) + const remove = host.querySelector(".image-attachment-remove") + assert(remove?.disabled) + remove.dispatchEvent(new window.MouseEvent("click", { bubbles: true })) + await settle() + assert.equal(input().value, text) + assert.equal(requests().length, count + 1) + assert.deepEqual(imageDrafts.get(scope), [image]) + assert(host.querySelector(`img[src="${image.dataUrl}"]`)) + await emit({ type: "sessionCommandCompleted", messageID: "unrelated-command" }) + assert.equal(button.getAttribute("aria-disabled"), "true") + await emit( + success + ? { type: "sessionCommandCompleted", messageID: request.messageID } + : { type: "sendMessageFailed", sessionID: sid, messageID: request.messageID, error: "Goal rejected" }, + ) + assert.equal(input().value, success ? "" : text) + assert.equal(input().readOnly, false) + assert.equal(!!host.querySelector(".prompt-goal-header"), !success) + assert.equal(drafts.has(scope), !success) + assert.equal(imageDrafts.has(scope), !success) + assert.equal(value.submitting(), false) + } + } + for (const success of [false, true]) { + for (const cancel of ["button", "escape"] as const) { + await seed("/goal") + submit(false) + await settle() + input().value = "Retain this objective" + input().dispatchEvent(new window.Event("input", { bubbles: true })) + submit(false) + await settle() + const request = requests().at(-1) + assert(request?.type === "sendCommand") + assert.equal(input().readOnly, true) + if (cancel === "escape") + input().dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true })) + if (cancel === "button") host.querySelector(".prompt-goal-header button")!.click() + await settle() + assert.equal(host.querySelector(".prompt-goal-header"), null) + assert.equal(input().readOnly, false) + assert.equal(input().getAttribute("aria-disabled"), "false") + assert.equal(input().value, "Retain this objective") + // Cancel must preserve even an unchanged draft when the accepted command later succeeds. + if (!success) { + input().value += " with a new edit" + input().dispatchEvent(new window.Event("input", { bubbles: true })) + } + const draft = input().value + await emit( + success + ? { type: "sessionCommandCompleted", messageID: request.messageID } + : { type: "sendMessageFailed", sessionID: "composer", messageID: request.messageID, error: "Rejected" }, + ) + assert.equal(input().value, draft) + value.setCurrentSessionID("other-composer") + await settle() + assert.equal(drafts.get(key), draft) + assert.deepEqual(imageDrafts.get(key), [image]) + value.setCurrentSessionID("composer") + await settle() + assert.equal(input().value, draft) + } + } + for (const success of [false, true]) { + await seed("/goal") + submit(false) + await settle() + input().value = "Original session objective" + input().dispatchEvent(new window.Event("input", { bubbles: true })) + submit(false) + await settle() + const request = requests().at(-1) + assert(request?.type === "sendCommand") + await emit({ type: "appendChatBoxMessage", text: "New context from the editor" }) + assert.equal(input().value, "Original session objective", "Host mutations must wait for admission") + savePromptDraft("acceptance:session:other-composer", "Other session draft", [], [{ ...image, id: "other" }]) + value.setCurrentSessionID("other-composer") + await settle() + assert.equal(input().readOnly, false) + assert.equal(input().value, "Other session draft") + input().value += " edited" + input().dispatchEvent(new window.Event("input", { bubbles: true })) + await emit( + success + ? { type: "sessionCommandCompleted", messageID: request.messageID } + : { type: "sendMessageFailed", sessionID: "composer", messageID: request.messageID, error: "Rejected" }, + ) + assert.equal(input().value, "Other session draft edited") + assert.deepEqual(imageDrafts.get("acceptance:session:other-composer"), [{ ...image, id: "other" }]) + value.setCurrentSessionID("composer") + await settle() + assert.equal( + input().value, + success ? "New context from the editor" : "Original session objective\n\nNew context from the editor", + ) + assert.equal(input().readOnly, false) + assert.equal(imageDrafts.has(key), !success) + } + { + await seed("/goal") + submit(false) + await settle() + const comment = { id: "goal-review", file: "test.ts", line: 1, side: "additions", comment: "Keep this review" } + const browser = { id: "goal-browser", sessionId: "composer", selector: "button" } + await emit({ type: "setChatBoxMessage", text: "Retain attachments", review: [comment], browser: [browser] }) + submit(false) + await settle() + assert.equal(input().readOnly, true) + const buttons = host.querySelectorAll( + ".prompt-review-row-remove, .prompt-review-comments-header [data-component=button]", + ) + assert.equal(buttons.length, 4) + buttons.forEach((button) => button.click()) + assert.equal(host.querySelectorAll(".prompt-review-row").length, 2) + await emit({ type: "appendReviewComments", sessionID: "composer", comments: [{ ...comment, id: "later-review" }] }) + await emit({ + type: "appendChatBoxMessage", + text: "", + browser: { ...browser, id: "later-browser", selector: "#later" }, + }) + await emit({ type: "appendChatBoxMessage", text: "Retain delayed editor text" }) + assert.equal(input().value, "Retain attachments") + assert.equal(host.querySelectorAll(".prompt-review-row").length, 2) + setComposer(false) + await settle() + assert.equal(drafts.get(key), "Retain attachments\n\nRetain delayed editor text") + assert.equal(reviewDrafts.get(key)?.length, 2) + assert.equal(browserDrafts.get(key)?.length, 2) + assert.deepEqual(imageDrafts.get(key), [image]) + const request = requests().at(-1) + assert(request?.type === "sendCommand") + await emit({ type: "sessionCommandCompleted", messageID: request.messageID }) + } setComposer(false) await settle() await catalog("org-a", [recommended.modelID], recommended.modelID) @@ -982,6 +1202,171 @@ try { await emit({ type: "webviewActiveChanged", active: true }) await check("root", "idle") await check("background", "idle") + + const goal = { text: "Fix the failing tests", active: true } + await emit({ type: "sessionUpdated", session: { ...info("root"), goal } }) + assert.equal(value.currentSession()?.goal?.text, goal.text) + assert.equal(value.currentSession()?.goal?.active, true) + value.setCurrentSessionID("background") + assert.equal(value.currentSession()?.goal, undefined) + value.setCurrentSessionID("root") + value.abort() + assert.deepEqual(sent.at(-1), { type: "abort", sessionID: "root", scope: "session" }) + await emit({ type: "sessionStatus", sessionID: "root", status: "idle" }) + for (const active of [true, false]) { + await emit({ type: "sessionUpdated", session: { ...info("root"), goal: { ...goal, active } } }) + assert.equal(value.sendMessage("Delayed human prompt"), true) + const start = sent.length + const stopped = () => sent.slice(start).filter((message) => message.type === "abort") + value.abort() + assert.equal(stopped().length, active ? 1 : 0) + if (active) { + await emit({ type: "sessionStatus", sessionID: "root", status: "idle" }) + await emit({ type: "sessionUpdated", session: { ...info("root"), goal: { ...goal, active: false } } }) + await emit({ type: "sessionStatus", sessionID: "root", status: "idle" }) + assert.equal(stopped().length, 1) + } + value.setCurrentSessionID("background") + await emit({ type: "sessionStatus", sessionID: "root", status: "busy" }) + assert.deepEqual(stopped().at(-1), { type: "abort", sessionID: "root", scope: "session" }) + assert.equal(stopped().length, active ? 2 : 1) + await emit({ type: "sessionStatus", sessionID: "root", status: "busy" }) + await emit({ type: "sessionStatus", sessionID: "root", status: "idle" }) + await emit({ type: "sessionStatus", sessionID: "root", status: "busy" }) + assert.equal(stopped().length, active ? 2 : 1) + await emit({ type: "sessionStatus", sessionID: "root", status: "idle" }) + value.setCurrentSessionID("root") + } + await emit({ type: "sessionUpdated", session: { ...info("root"), goal } }) + await emit({ type: "sessionStatus", sessionID: "root", status: "busy" }) + await emit({ type: "questionRequest", question: { id: "goal-question", sessionID: "root", questions: [] } }) + await emit({ + type: "suggestionRequest", + suggestion: { id: "goal-suggestion", sessionID: "root", text: "Continue?", actions: [] }, + }) + const count = value.messages().length + for (const phase of ["ready", "loading", "empty"]) { + if (phase === "loading") await emit({ type: "providersLoading" }) + if (phase === "empty") + await emit({ + type: "providersLoaded", + ready: true, + providers: {}, + connected: [], + defaults: {}, + authMethods: {}, + authStates: {}, + }) + for (const [args, control] of [ + ["pause", true], + ["", true], + ["resume", false], + ["clear", true], + ["A new goal", false], + ] as const) { + const before = snapshot("root") + const start = sent.length + const accepted = value.sendCommand( + "goal", + args, + "kilo", + "unavailable", + undefined, + undefined, + undefined, + undefined, + control ? { agent: "ask", model: "kilo/unavailable", variant: "high" } : undefined, + ) + const posted = sent.slice(start) + if (!control && phase !== "ready") { + assert.equal(accepted, false) + assert.deepEqual(posted, []) + assert.equal(snapshot("root"), before) + continue + } + assert.equal(accepted, true) + const command = sent.at(-1) + assert(command?.type === "sendCommand") + assert.equal(posted.filter((message) => message.type === "sendCommand").length, 1) + assert.equal( + posted.some((message) => + ["questionReply", "questionReject", "suggestionDismiss", "permissionResponse"].includes(message.type), + ), + false, + ) + assert.equal(command.sessionID, "root") + assert.equal(command.arguments, args) + if (control) { + assert.deepEqual( + posted.map((message) => message.type), + ["sendCommand"], + ) + assert.equal(command.providerID, undefined) + assert.equal(command.modelID, undefined) + assert.equal(command.agent, undefined) + assert.equal(command.variant, undefined) + } + assert.equal(value.messages().length, count) + assert.equal(value.submitting(), true) + await emit({ type: "sessionCommandCompleted", messageID: command.messageID }) + assert.equal(value.submitting(), false) + if (control) assert.equal(snapshot("root"), before) + assert.equal(value.status(), "busy") + assert.equal(value.questions().length, 1) + assert.equal(value.suggestions().length, 1) + } + for (const args of ["", "pause", "clear", "resume", "A new goal"]) { + const control = ["", "pause", "clear"].includes(args) + const before = snapshot("cloud:preview") + const start = sent.length + const messageID = `goal-cloud-${phase}-${args}` + const accepted = value.sendCommand( + "goal", + args, + undefined, + undefined, + undefined, + undefined, + undefined, + "cloud:preview", + { messageID }, + ) + if (!control && phase !== "ready") { + assert.equal(accepted, false) + assert.deepEqual(sent.slice(start), []) + continue + } + assert.equal(accepted, true) + const request = sent.at(-1) + assert(request?.type === "importAndSend") + assert.equal(request.cloudSessionId, "preview") + assert.equal(request.command, "goal") + assert.equal(request.commandArgs, args) + assert.equal(request.messageID, messageID) + if (control) { + assert.deepEqual( + sent.slice(start).map((message) => message.type), + ["importAndSend"], + ) + assert.equal(request.providerID, undefined) + assert.equal(request.modelID, undefined) + assert.equal(request.agent, undefined) + assert.equal(request.variant, undefined) + assert.equal(snapshot("cloud:preview"), before) + } + } + } + await catalog("org-a", [recommended.modelID], recommended.modelID) + await emit({ type: "sessionUpdated", session: { ...info("root"), goal: { ...goal, active: false } } }) + assert.equal(value.currentSession()?.goal?.active, false) + await emit({ type: "sessionUpdated", session: { ...info("root"), goal: null } }) + assert.equal(value.currentSession()?.goal, null) + await emit({ type: "questionResolved", requestID: "goal-question" }) + await emit({ type: "suggestionResolved", requestID: "goal-suggestion" }) + await emit({ type: "sessionStatus", sessionID: "root", status: "idle" }) + const idle = sent.length + value.abort() + assert.equal(sent.length, idle) for (const update of [setOperation, setRun]) { update(true) await settle() diff --git a/packages/kilo-vscode/tests/session-dock-stability.spec.ts b/packages/kilo-vscode/tests/session-dock-stability.spec.ts index a626553389e..fb85cbbf503 100644 --- a/packages/kilo-vscode/tests/session-dock-stability.spec.ts +++ b/packages/kilo-vscode/tests/session-dock-stability.spec.ts @@ -151,6 +151,133 @@ test("the indicator stays a centered lane on a wide surface", async ({ page }) = expect(Math.abs(lane.leftGap - lane.rightGap)).toBeLessThanOrEqual(2) }) +for (const width of [340, 532, 720, 1400]) { + test(`goal preserves session actions and spinner geometry at ${width}px`, async ({ page }) => { + await openStory(page) + await page.setViewportSize({ width, height: 640 }) + const spinner = page.locator('.working-indicator [data-component="spinner"]') + await page.getByTestId("toggle-busy").click() + await expect(spinner).toBeVisible() + // CSS motion overrides do not clear StatusText's JavaScript width lock. + await expect(page.locator(".working-status")).not.toHaveAttribute("data-swap") + const baseline = await spinner.boundingBox() + await page.getByTestId("toggle-busy").click() + await page.getByTestId("toggle-goal").click() + + const actions = page.locator(".session-actions-row") + const goal = actions.locator(".session-goal-action") + const status = page.getByRole("img", { name: "Goal: Active" }) + await expect(goal).toBeVisible() + await expect(goal.locator("svg").first()).toHaveAttribute("viewBox", "0 0 20 20") + await expect(goal.locator("svg").first().locator("circle")).toHaveCount(3) + await expect(status).toBeHidden() + for (const name of ["New Session", "Fork Session", "Move to Worktree"]) { + await expect(actions.getByRole("button", { name, exact: true })).toBeVisible() + } + const style = (el: Element) => { + const css = getComputedStyle(el) + return { + height: el.getBoundingClientRect().height, + font: css.fontSize, + padding: css.padding, + background: css.backgroundColor, + } + } + expect(await goal.evaluate(style)).toEqual( + await actions.getByRole("button", { name: "Fork Session", exact: true }).evaluate(style), + ) + const anchor = await goal.boundingBox() + const dock = await page.locator(".session-dock").boundingBox() + if (!anchor || !dock) throw new Error("Goal or session dock missing") + for (const button of await actions.locator("button:not(.session-goal-action)").all()) { + const box = await button.boundingBox() + if (!box) throw new Error("Session action missing") + expect(box.x).toBeGreaterThanOrEqual(dock.x) + expect(box.x + box.width).toBeLessThanOrEqual(dock.x + dock.width) + expect(box.y + box.height).toBeLessThanOrEqual(dock.y + dock.height) + expect(box.x + box.width <= anchor.x || box.y + box.height <= anchor.y || box.y >= anchor.y + anchor.height).toBe( + true, + ) + } + + const idle = await geometry(page) + await goal.click() + await expect(page.getByRole("menuitem", { name: "Clear goal" })).toBeVisible() + await page.getByTestId("toggle-busy").evaluate((el) => { + if (!(el instanceof HTMLElement)) throw new Error("Status control missing") + el.click() + }) + await expect(spinner).toBeVisible() + await expect(status).toBeVisible() + await expect(goal).toBeHidden() + await expect(actions).toBeHidden() + await expect(page.getByRole("menuitem", { name: "Clear goal" })).toBeHidden() + await expect(page.locator(".working-status")).not.toHaveAttribute("data-swap") + const bounds = await spinner.boundingBox() + if (!bounds || !baseline) throw new Error("Spinner missing") + expect(bounds.x).toBe(baseline.x) + expect(bounds.width).toBe(baseline.width) + expect(bounds.height).toBe(baseline.height) + expect(await geometry(page)).toEqual(idle) + await expect(status.locator("svg circle")).toHaveCount(3) + if (width >= 532) { + await expect(status.locator(".session-goal-status-content")).not.toHaveAttribute("data-compact") + await expect(status.locator(".session-goal-status-label")).toBeVisible() + } + await status.hover() + await expect(page.getByRole("tooltip")).toContainText("Keep the session controls available") + await page.getByTestId("toggle-busy").hover() + await page.keyboard.press("Tab") + await status.focus() + await expect(page.getByRole("tooltip")).toContainText("Goal: Active") + await status.click() + await expect(page.getByRole("menuitem", { name: "Clear goal" })).toBeHidden() + await page.getByTestId("toggle-busy").click() + await expect(actions).toBeVisible() + await expect(goal).toBeVisible() + await expect(status).toBeHidden() + await expect(page.getByRole("menuitem", { name: "Clear goal" })).toBeHidden() + }) +} + +test("goal label fits the remaining space and recovers after compaction", async ({ page }) => { + await openStory(page) + await page.setViewportSize({ width: 380, height: 640 }) + await page.getByTestId("toggle-goal").click() + await page.getByTestId("toggle-busy").click() + const status = page.getByRole("img", { name: "Goal: Active" }) + const label = status.locator(".session-goal-status-label") + await expect(status.locator(".session-goal-status-content")).not.toHaveAttribute("data-compact") + await expect(label).toBeVisible() + + await page.getByTestId("next-status").click() + await expect(status.locator(".session-goal-status-content")).toHaveAttribute("data-compact", "") + await expect(status.locator("svg")).toBeVisible() + await status.hover() + await expect(page.getByRole("tooltip")).toContainText("Keep the session controls available") + + await page.setViewportSize({ width: 660, height: 640 }) + await expect(status.locator(".session-goal-status-content")).not.toHaveAttribute("data-compact") + await expect(label).toBeVisible() + await page.setViewportSize({ width: 380, height: 640 }) + await expect(status.locator(".session-goal-status-content")).toHaveAttribute("data-compact", "") + await page.getByTestId("next-status").click() + await expect(status.locator(".session-goal-status-content")).not.toHaveAttribute("data-compact") + + await page.locator(".chat-view").evaluate((el) => { + if (!(el instanceof HTMLElement)) throw new Error("Chat missing") + el.style.display = "none" + }) + await expect(status).toBeHidden() + await page.locator(".chat-view").evaluate((el) => { + if (!(el instanceof HTMLElement)) throw new Error("Chat missing") + el.style.removeProperty("display") + }) + await expect(status).toBeVisible() + await expect(status.locator(".session-goal-status-content")).not.toHaveAttribute("data-compact") + await expect(label).toBeVisible() +}) + test("the counter keeps its width as it ticks", async ({ page }) => { await openStory(page) await page.getByTestId("toggle-busy").click() diff --git a/packages/kilo-vscode/tests/unit/abort-state.test.ts b/packages/kilo-vscode/tests/unit/abort-state.test.ts index 1b37396b2da..ecffeca506b 100644 --- a/packages/kilo-vscode/tests/unit/abort-state.test.ts +++ b/packages/kilo-vscode/tests/unit/abort-state.test.ts @@ -30,6 +30,30 @@ describe("pending prompt abort state", () => { expect(aborts.update("session", "busy")).toBe(false) }) + it("preserves pending cancellation across an immediate goal abort becoming idle", () => { + const aborts = createAbortState() + + expect(aborts.request("session", "idle", "message", true)).toBe(false) + expect(aborts.update("session", "idle")).toBe(false) + expect(aborts.update("session", "idle")).toBe(false) + aborts.finish("other") + expect(aborts.update("other", "busy")).toBe(false) + expect(aborts.update("session", "busy")).toBe(true) + expect(aborts.update("session", "busy")).toBe(false) + expect(aborts.update("session", "idle")).toBe(false) + expect(aborts.update("session", "busy")).toBe(false) + }) + + it("clears preserved cancellation when its submission finishes before becoming busy", () => { + const aborts = createAbortState() + + expect(aborts.request("session", "idle", "message")).toBe(false) + expect(aborts.request("session", "idle", "message", true)).toBe(false) + expect(aborts.update("session", "idle")).toBe(false) + aborts.finish("message") + expect(aborts.update("session", "busy")).toBe(false) + }) + it("allows retrying an abort while the session remains active", () => { const aborts = createAbortState() diff --git a/packages/kilo-vscode/tests/unit/attention.test.ts b/packages/kilo-vscode/tests/unit/attention.test.ts index 2e3acb935d9..41fe2c662d4 100644 --- a/packages/kilo-vscode/tests/unit/attention.test.ts +++ b/packages/kilo-vscode/tests/unit/attention.test.ts @@ -62,6 +62,33 @@ describe("AttentionService", () => { test.service.dispose() }) + it.each([{ "kilo.goal": { text: "Goal", active: false } }, {}])( + "suppresses active goal completions but retains attention and restores normal completion after %j", + (metadata) => { + const test = setup() + test.event( + event({ + type: "sync", + name: "session.updated.1", + data: { sessionID: "s1", info: { metadata: { "kilo.goal": { text: "Goal", active: true } } } }, + }), + ) + test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } })) + test.event(event({ type: "session.turn.close", properties: { sessionID: "s1", reason: "completed" } })) + expect(test.sounds).toEqual([]) + test.event(event({ type: "question.asked", properties: { id: "q1", sessionID: "s1" } })) + test.event(event({ type: "permission.asked", properties: { id: "p1", sessionID: "s1" } })) + test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } })) + test.event(event({ type: "session.error", properties: { sessionID: "s1", error: { name: "ApiError" } } })) + expect(test.sounds).toEqual(["question", "permission", "error"]) + test.event(event({ type: "sync", name: "session.updated.1", data: { sessionID: "s1", info: { metadata } } })) + test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } })) + test.event(event({ type: "session.turn.close", properties: { sessionID: "s1", reason: "completed" } })) + expect(test.sounds).toEqual(["question", "permission", "error", "done"]) + test.service.dispose() + }, + ) + it("deduplicates question and permission requests", () => { const test = setup() test.event(event({ type: "question.asked", properties: { id: "q1", sessionID: "s1" } })) diff --git a/packages/kilo-vscode/tests/unit/cloud-session-handler.test.ts b/packages/kilo-vscode/tests/unit/cloud-session-handler.test.ts index accf86ee0a3..f5ea96ed08d 100644 --- a/packages/kilo-vscode/tests/unit/cloud-session-handler.test.ts +++ b/packages/kilo-vscode/tests/unit/cloud-session-handler.test.ts @@ -34,6 +34,41 @@ function context(sent: unknown[]) { } describe("cloud session preview handler", () => { + it.each(["", "pause", "clear", "resume"])("only shows bare goal status after cloud import: %j", async (args) => { + const sent: unknown[] = [] + const notices: string[] = [] + const ctx: CloudSessionContext = { + ...context(sent), + client: { + kilo: { + cloud: { + session: { import: async () => ({ data: { id: "local", time: { created: 1, updated: 1 } } }) }, + }, + }, + session: { + command: async () => ({ data: { parts: [{ type: "text", text: "Goal paused" }] } }), + }, + } as unknown as CloudSessionContext["client"], + notify: (message) => notices.push(message), + } + await handleImportAndSend( + ctx, + "cloud", + "/goal", + "message", + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + "goal", + args, + ) + expect(notices).toEqual(args ? [] : ["Goal paused"]) + expect(sent).toContainEqual({ type: "sessionCommandCompleted", messageID: "message" }) + }) + it("reports a failure when the CLI preview request stalls", async () => { const timeout = AbortSignal.timeout AbortSignal.timeout = () => { diff --git a/packages/kilo-vscode/tests/unit/command-completion.test.ts b/packages/kilo-vscode/tests/unit/command-completion.test.ts index 9c1373e9171..82b545ccff9 100644 --- a/packages/kilo-vscode/tests/unit/command-completion.test.ts +++ b/packages/kilo-vscode/tests/unit/command-completion.test.ts @@ -1,8 +1,27 @@ import { describe, expect, it } from "bun:test" -import { completesWithoutStatus } from "../../src/kilo-provider/command-completion" +import { completesWithoutStatus, goalControl } from "../../src/kilo-provider/command-completion" + +describe("goalControl", () => { + it.each(["", " \n", "pause", " pause ", "clear"])("accepts model-free goal %j", (args) => { + expect(goalControl("goal", args)).toBe(true) + }) + + it.each([ + ["goal", "resume"], + ["goal", "Fix failing tests"], + ["goal", "status"], + ["goal", "pause after tests"], + ["review", "pause"], + ["Goal", ""], + ])("keeps /%s %s on the normal command path", (command, args) => { + expect(goalControl(command, args)).toBe(false) + }) +}) describe("completesWithoutStatus", () => { - it("matches only deprecated static review aliases", () => { + it("matches goal and deprecated static review aliases", () => { + expect(completesWithoutStatus("goal")).toBe(true) + expect(completesWithoutStatus("goals")).toBe(false) expect(completesWithoutStatus("local-review")).toBe(true) expect(completesWithoutStatus("local-review-uncommitted")).toBe(true) expect(completesWithoutStatus("review")).toBe(false) diff --git a/packages/kilo-vscode/tests/unit/goal-composer.test.ts b/packages/kilo-vscode/tests/unit/goal-composer.test.ts new file mode 100644 index 00000000000..77470b54e37 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/goal-composer.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "bun:test" +import { createRoot, createSignal } from "solid-js" +import { useGoalComposer } from "../../webview-ui/src/components/chat/goal/useGoalComposer" + +describe("goal composer", () => { + it.each(["accepted", "rejected", "changed"])("preserves submission ownership when %s", (result) => { + createRoot((dispose) => { + const [key, setKey] = createSignal("pending:first") + const sent: Parameters[1]["send"]>[] = [] + const cleared: string[] = [] + const goal = useGoalComposer(key, { + send: (...args) => { + sent.push(args) + return result !== "rejected" + }, + fingerprint: () => (result === "changed" ? "edited" : "draft"), + clear: (scope) => cleared.push(scope), + }) + let resets = 0 + expect(goal.prepare("/goal", () => resets++)).toBe(false) + expect(resets).toBe(1) + expect(goal.active()).toBe(true) + expect(goal.prepare("pause", () => resets++)).toBe(true) + const files = [{ type: "file" as const, mime: "text/plain", url: "data:text/plain,context" }] + goal.send("other", "draft", ["goal", "-- pause"]) + expect(sent).toHaveLength(0) + goal.send(key(), "draft", ["goal", "-- pause", "provider", "model", files, "draft-id", "context", null]) + expect(sent).toHaveLength(1) + expect(sent.at(0)?.slice(0, 8)).toEqual([ + "goal", + "-- pause", + "provider", + "model", + files, + "draft-id", + "context", + null, + ]) + const id = sent.at(0)?.[8] + if (!id?.messageID) throw new Error("Missing message ID") + expect(goal.pending()).toBe(result !== "rejected") + goal.move(key(), "session:first") + setKey("session:first") + goal.finish(id.messageID, true) + expect(goal.pending()).toBe(false) + expect(goal.active()).toBe(result === "rejected") + expect(cleared).toEqual(result === "accepted" ? ["session:first"] : []) + dispose() + }) + }) + + it("keeps goal mode on failure and exits only on its successful acknowledgement", () => { + createRoot((dispose) => { + const goal = useGoalComposer(() => "session:first", { + send: () => true, + fingerprint: () => "draft", + clear: () => { + throw new Error("No submitted draft to clear") + }, + }) + goal.activate() + expect(goal.ready("")).toBe(false) + expect(goal.ready(" \n ")).toBe(false) + expect(goal.ready("pause")).toBe(true) + expect(goal.ready("First step\n\nSecond step")).toBe(true) + goal.begin("request:first", "session:first") + expect(goal.pending()).toBe(true) + expect(goal.finish("other", true)).toBeUndefined() + expect(goal.active()).toBe(true) + expect(goal.finish("request:first", false)).toBe("session:first") + expect(goal.active()).toBe(true) + expect(goal.pending()).toBe(false) + goal.begin("request:second", "session:first") + expect(goal.finish("request:second", true)).toBe("session:first") + expect(goal.active()).toBe(false) + dispose() + }) + }) + + it("scopes mode and pending sends to their draft, including session creation", () => { + createRoot((dispose) => { + const [key, setKey] = createSignal("pending:first") + const goal = useGoalComposer(key, { + send: () => true, + fingerprint: () => "draft", + clear: () => { + throw new Error("No submitted draft to clear") + }, + }) + goal.activate() + goal.begin("request", key()) + goal.move(key(), "session:first") + setKey("session:first") + expect(goal.active()).toBe(true) + expect(goal.pending()).toBe(true) + setKey("session:second") + expect(goal.active()).toBe(false) + expect(goal.pending()).toBe(false) + goal.activate() + expect(goal.finish("request", true)).toBe("session:first") + expect(goal.active()).toBe(true) + goal.cancel() + expect(goal.active()).toBe(false) + expect(goal.ready("")).toBe(true) + dispose() + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts index 61c4d40375f..aa04d792f42 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts @@ -238,6 +238,7 @@ describe("KiloProvider follow-up sessions", () => { parentID: null, revert: null, summary: null, + goal: null, }, }) }) @@ -406,6 +407,7 @@ describe("KiloProvider follow-up sessions", () => { parentID: null, revert: null, summary: null, + goal: null, }, activate: true, }, diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts index 4557ec85c3e..cee9dd9747f 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect } from "bun:test" +import { describe, it, expect, spyOn } from "bun:test" +import * as vscode from "vscode" import * as fs from "fs/promises" import * as os from "os" import * as path from "path" @@ -113,6 +114,9 @@ type ProviderInternals = { webview: { postMessage: (message: unknown) => Promise } | null startStatsPolling: () => void contextSessionID: string | undefined + checkpoints: Map> + sessionStatusMap: Map + retryAbortControllers: Map openChanges: (sessionID?: string, turnID?: string, comment?: PRReviewCommentData) => Promise refreshGitStatus: (directory?: string, sessionID?: string) => Promise refreshGitStatusFromParts: (parts: unknown[], sessionID?: string) => Promise @@ -568,6 +572,119 @@ describe("KiloProvider route integration", () => { expect(failed, "expected a sendMessageFailed message for the ambiguous share command").toBeTruthy() }) + function goal(error?: string) { + const { connection } = mockConnection() + const calls: unknown[] = [] + const sent: unknown[] = [] + const client = connection.getClient() + client.session.command = async (input) => { + calls.push(input) + return ( + error + ? { error, response: new Response(null, { status: 409 }) } + : { + data: { + parts: [ + { type: "text", text: "Goal paused" }, + { type: "reasoning", text: "hidden" }, + ], + }, + } + ) as Awaited> + } + const internal = new KiloProvider({} as never, connection, undefined, { + rootDirectory: () => "/goal/worktree", + }) as unknown as ProviderInternals + connect(internal) + internal.isWebviewReady = true + internal.webview = { postMessage: async (message) => sent.push(message) } + return { internal, calls, sent } + } + + it.each([ + { args: "Fix failing tests", control: false }, + { args: "resume", control: false }, + { args: "", control: true }, + { args: "pause", control: true }, + { args: "clear", control: true }, + ])( + "waits for checkpoints before starting or resuming goals but sends controls immediately: %j", + async ({ args, control }) => { + const { internal, calls, sent } = goal() + const pending = Promise.withResolvers() + const retry = new AbortController() + internal.checkpoints.set("goal-session", pending.promise) + internal.retryAbortControllers.set("goal-session", retry) + internal.sessionStatusMap.set("goal-session", "busy") + const notice = spyOn(vscode.window, "showInformationMessage").mockResolvedValue(undefined) + try { + const send = internal.handleSendCommand( + "goal", + args, + "goal-message", + "goal-session", + undefined, + "test", + "selected", + "ask", + "high", + ) + await Promise.resolve() + await Promise.resolve() + expect(calls).toHaveLength(control ? 1 : 0) + if (!control) { + expect(sent).not.toContainEqual({ type: "sessionCommandCompleted", messageID: "goal-message" }) + pending.resolve() + } + await send + expect(calls).toHaveLength(1) + expect(calls.at(0)).toMatchObject({ + sessionID: "goal-session", + directory: "/goal/worktree", + command: "goal", + arguments: args, + model: control ? undefined : "test/selected", + agent: control ? undefined : "ask", + variant: control ? undefined : "high", + }) + expect(sent).toContainEqual({ type: "sessionCommandCompleted", messageID: "goal-message" }) + expect(sent).not.toContainEqual(expect.objectContaining({ type: "sessionStatus" })) + expect(internal.sessionStatusMap.get("goal-session")).toBe("busy") + expect(internal.retryAbortControllers.get("goal-session")).toBe(retry) + expect(notice.mock.calls).toEqual(args ? [] : [["Goal paused"]]) + } finally { + pending.resolve() + notice.mockRestore() + } + }, + ) + + it("does not resume a goal when the pending checkpoint fails", async () => { + const { internal, calls, sent } = goal() + const pending = Promise.withResolvers() + internal.checkpoints.set("goal-session", pending.promise) + const send = internal.handleSendCommand("goal", "resume", "goal-message", "goal-session") + await Promise.resolve() + await Promise.resolve() + expect(calls).toHaveLength(0) + pending.reject(new Error("Checkpoint failed")) + await send + expect(calls).toHaveLength(0) + expect(sent).toContainEqual( + expect.objectContaining({ type: "sendMessageFailed", messageID: "goal-message", error: "Checkpoint failed" }), + ) + expect(sent).not.toContainEqual({ type: "sessionCommandCompleted", messageID: "goal-message" }) + }) + + it("reports goal command errors without marking an active run idle", async () => { + const { internal, sent } = goal("Goal is unavailable") + internal.sessionStatusMap.set("goal-session", "busy") + await internal.handleSendCommand("goal", "resume", undefined, "goal-session") + expect(sent).toContainEqual(expect.objectContaining({ type: "sendMessageFailed", error: "Goal is unavailable" })) + expect(sent).not.toContainEqual(expect.objectContaining({ type: "sessionStatus" })) + expect(internal.sessionStatusMap.get("goal-session")).toBe("busy") + }) + it("runs a share command on a unique session route against its exact directory", async () => { const routes = new ProjectRouteService() routes.registerProject("a", "/repo/a", 1) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts index 10952ec9983..af16b37863b 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -165,7 +165,42 @@ describe("sessionToWebview", () => { const result = sessionToWebview(makeSession()) expect(result.revert).toBeNull() expect(result.summary).toBeNull() - }) + expect(result.goal).toBeNull() + }) + + it.each([true, false])("projects the saved goal with active=%s", (active) => { + const goal = { text: "Fix the failing tests", active } + const result = sessionToWebview(makeSession({ metadata: { "kilo.goal": goal, unrelated: "private" } })) + expect(result.goal).toEqual(goal) + expect(result).not.toHaveProperty("metadata") + }) + + it("clears the saved goal through JSON serialization and a session merge", () => { + const saved = sessionToWebview(makeSession({ metadata: { "kilo.goal": { text: "Fix tests", active: true } } })) + const cleared = JSON.parse(JSON.stringify(sessionToWebview(makeSession({ metadata: {} })))) + expect({ ...saved, ...cleared }.goal).toBeNull() + }) + + it.each(["active", "complete", "blocked", "paused"] as const)( + "carries %s goal state and report through JSON", + (status) => { + const goal = { + text: "Fix tests", + status, + active: status === "active", + reason: "Reported by the working model, not independently verified.", + } + const result = sessionToWebview(makeSession({ metadata: { "kilo.goal": goal } })) + expect(JSON.parse(JSON.stringify(result)).goal).toEqual(goal) + }, + ) + + it.each([null, "text", { text: 1, active: true }, { text: "Goal" }, { text: "Goal", active: "true" }])( + "ignores invalid goal metadata %j", + (goal) => { + expect(sessionToWebview(makeSession({ metadata: { "kilo.goal": goal } })).goal).toBeNull() + }, + ) it("preserves the workspace restoration outcome from a revert response", () => { const session = { diff --git a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts index e4362d066f8..93cdca6aab1 100644 --- a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts @@ -57,11 +57,13 @@ describe("sendMessage dismisses pending tool requests", () => { }) it("dismisses suggestions before sending", () => { - expect(body).toContain("dismissSuggestion") + expect(body).toContain("dismiss(sid)") + expect(extractFunctionBody(source, "dismiss")).toContain("dismissSuggestion") }) it("rejects questions before sending", () => { - expect(body).toContain("dismissQuestion") + expect(body).toContain("dismiss(sid)") + expect(extractFunctionBody(source, "dismiss")).toContain("dismissQuestion") }) }) @@ -74,11 +76,13 @@ describe("sendCommand dismisses pending tool requests", () => { }) it("dismisses suggestions before sending", () => { - expect(body).toContain("dismissSuggestion") + expect(body).toContain("dismiss(sid)") + expect(extractFunctionBody(source, "dismiss")).toContain("dismissSuggestion") }) it("rejects questions before sending", () => { - expect(body).toContain("dismissQuestion") + expect(body).toContain("dismiss(sid)") + expect(extractFunctionBody(source, "dismiss")).toContain("dismissQuestion") }) it("applies model, agent, and variant overrides when provided by a command", () => { @@ -318,18 +322,24 @@ describe("sendMessage / sendCommand draft id contract", () => { it("sendCommand seeds the pending agent before resolving draft-scoped settings", () => { const body = extractFunctionBody(source, "sendCommand") expect(body).toMatch( - /if \(!sid && !draftID && effectiveDraftID\) agentDrafts\.seed\(effectiveDraftID\)[\s\S]*const settings = submission\(scope, effectiveSelection\)/, + /if \(!sid && !draftID && effectiveDraftID\) agentDrafts\.seed\(effectiveDraftID\)[\s\S]*submission\(scope, effectiveSelection\)/, ) }) it("sendMessage and sendCommand post the settings returned by submission", () => { expect(extractFunctionBody(source, "sendMessage")).toContain("const settings = submission(scope, selection)") expect(extractFunctionBody(source, "sendCommand")).toContain( - "const settings = submission(scope, effectiveSelection)", + "const { model, ...settings } = submission(scope, effectiveSelection)", ) expect(extractFunctionBody(source, "submission")).toContain("agent: resolvePromptAgent({") }) + it("does not resolve submission defaults for model-free Goal controls", () => { + const body = extractFunctionBody(source, "sendCommand") + expect(body).toMatch(/if \(!effectiveSelection\) return\s+const \{ model, \.\.\.settings \} = submission/) + expect(body).not.toContain("effectiveSelection ?? undefined") + }) + it("createSession and clearCurrentSession do not pin the provisional default agent", () => { expect(extractFunctionBody(source, "createSession")).toContain("setPendingAgentSelection(null)") expect(extractFunctionBody(source, "createSession")).not.toContain("setPendingAgentSelection(defaultAgent())") @@ -414,11 +424,13 @@ describe("PromptInput send origin contract", () => { const end = source.indexOf("\n return (", start) const body = source.slice(start, end) const send = Math.max(body.indexOf("session.sendMessage("), body.indexOf("session.sendCommand(")) - const append = body.lastIndexOf("history.append(draft)") + const clear = body.indexOf("clearDraft(key, draft)") + const append = body.lastIndexOf("history.append(value)") const guard = body.indexOf("if (draftKey() !== key) return") expect(send).toBeGreaterThan(-1) - expect(append).toBeGreaterThan(send) + expect(clear).toBeGreaterThan(send) + expect(append).toBeGreaterThan(clear) expect(append).toBeLessThan(guard) expect(body.indexOf('setText("")', guard)).toBeGreaterThan(guard) }) diff --git a/packages/kilo-vscode/tests/unit/session-dock.test.ts b/packages/kilo-vscode/tests/unit/session-dock.test.ts index 42339697701..67e870a235f 100644 --- a/packages/kilo-vscode/tests/unit/session-dock.test.ts +++ b/packages/kilo-vscode/tests/unit/session-dock.test.ts @@ -47,7 +47,7 @@ describe("session dock layout", () => { it("stacks both states in one grid cell so the row measures the taller one", () => { const css = read("webview-ui/src/styles/chat-layout.css") const dock = css.match(/\.session-dock \{([\s\S]*?)\}/) - const state = css.match(/\.session-dock-state \{([\s\S]*?)\}/) + const state = css.match(/^\.session-dock-state \{([\s\S]*?)\}/m) expect(dock).not.toBeNull() expect(state).not.toBeNull() expect(dock![1]).toContain("display: grid") @@ -83,6 +83,52 @@ describe("session dock layout", () => { expect(read("webview-ui/src/components/shared/WorkingIndicator.tsx")).not.toContain("working-indicator-slot") }) + it("keeps Goal inside the shared session actions", () => { + const view = read("webview-ui/src/components/chat/ChatView.tsx") + expect(view).toContain("hasActions={() => !props.readonly && (hasActions(hasMessages()) || !!goal())}") + expect(view).toContain("actions={(control) => renderActions(hasMessages(), control)}") + expect(view).toContain("{control()}") + }) + + it("only exposes Goal controls while the action row is available", () => { + const dock = read("webview-ui/src/components/chat/SessionDock.tsx") + const goal = read("webview-ui/src/components/chat/goal/useGoalDock.tsx") + const indicator = read("webview-ui/src/components/shared/WorkingIndicator.tsx") + expect(indicator).not.toMatch(/goal|DropdownMenu|Tooltip/) + expect(dock).toContain("") + expect(goal).toContain('class="session-goal-action"') + expect(goal).toContain('variant="ghost"') + expect(goal).toContain("disabled={props.readonly || !actions()}") + expect(goal).toContain("if (!actions() || !goal()) setOpen(false)") + expect(dock).toContain("props.actions?.(goal.control)") + expect(dock).toContain("const goal = useGoalDock({") + expect(goal).toContain('session.sendCommand("goal", goal().active ? "pause" : "resume")') + expect(goal).toContain('session.sendCommand("goal", "clear")') + expect(read("webview-ui/src/components/chat/PromptInput.tsx")).not.toContain('"session.goal.label"') + const working = dock.match(/const working = \(\) =>([^\n]*)/)?.[1] + expect(working).not.toContain("goal()") + expect(dock).toContain("const active = () => working() || actions()") + expect(goal).toContain("working() && goal()?.active") + expect(dock).toContain("{goal.status()}") + }) + + it("shares action button styles without modifying the loading indicator", () => { + const layout = read("webview-ui/src/styles/chat-layout.css") + const css = layout + read("webview-ui/src/styles/goal.css") + expect(layout).not.toContain(".session-goal-") + expect(read("webview-ui/src/styles/chat.css")).toContain('@import "./goal.css"') + const indicator = css.match(/\.working-indicator \{([\s\S]*?)\}/)?.[1] + expect(css).not.toContain('.session-goal-action[data-component="button"]') + expect(css).not.toContain(".session-dock[data-goal]") + expect(css).toContain(".session-goal-status") + expect(css).not.toContain(".session-goal-dot") + expect(css).not.toContain("@container chat (max-width: 640px)") + expect(indicator).toContain("gap: 8px") + expect(indicator).toContain("padding: 8px 16px") + expect(css).not.toContain("working-goal") + expect(css).not.toContain(".working-indicator[data-goal]") + }) + it("keeps the composer column as the only owner of the row", () => { // A second copy inside the scrollable transcript would resize the scroll // content on every turn boundary again. diff --git a/packages/kilo-vscode/tests/unit/use-slash-command.test.ts b/packages/kilo-vscode/tests/unit/use-slash-command.test.ts index b3144520a66..35639317a33 100644 --- a/packages/kilo-vscode/tests/unit/use-slash-command.test.ts +++ b/packages/kilo-vscode/tests/unit/use-slash-command.test.ts @@ -38,6 +38,33 @@ function setup( } describe("worktree update slash action", () => { + it("activates a composer selection without turning the goal shortcut into a client action", () => { + let active = false + const ctx = setup(() => {}, { + extra: [ + { + name: "goal", + hints: [], + select: () => { + active = true + }, + }, + ], + }) + const textarea = { + value: "/goal\nKeep this objective", + setSelectionRange: () => {}, + } as unknown as HTMLTextAreaElement + ctx.slash.onInput(textarea.value, 5) + const entry = ctx.slash.results().find((item) => item.name === "goal")! + expect(entry.action).toBeUndefined() + ctx.slash.select(entry, textarea, () => {}) + expect(active).toBe(true) + expect(textarea.value).toBe("\nKeep this objective") + expect(ctx.slash.show()).toBe(false) + ctx.dispose() + }) + it("uses the current worktree selection and preserves text after the action", () => { const state = { selected: "first", sent: "", text: "/update-from-base keep this draft" } const ctx = setup(() => {}, { diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index 5bc39f99f66..b859c932150 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -68,6 +68,7 @@ export const ChatView: Component = (props) => { const canContinueInWorktree = () => props.continueInWorktree === true const id = () => session.currentSessionID() + const goal = () => session.currentSession()?.goal // Counts the in-flight first message too, so the dock reserves the same row on // the very first send instead of growing once the message lands. const hasMessages = () => session.messages().length > 0 || session.submitting() @@ -103,7 +104,8 @@ export const ChatView: Component = (props) => { const suggesting = () => isSuggesting(blocked(), familySuggestions().length) // Session is busy only because a question tool call is pending — prompt should behave as idle const questioning = () => isQuestioning(blocked(), familyQuestions().length) - const dock = () => !props.readonly || !!permissionRequest() || session.submitting() || session.status() !== "idle" + const dock = () => + !props.readonly || !!goal() || !!permissionRequest() || session.submitting() || session.status() !== "idle" // The session dock stays empty while another surface owns the interaction: // a permission card, a pending question or suggestion, or agent requirements. // A spinner there would claim the agent is working while it waits on the user. @@ -112,7 +114,12 @@ export const ChatView: Component = (props) => { onMount(() => { if (props.readonly) return const handler = (e: KeyboardEvent) => { - if (e.key !== "Escape" || (!session.submitting() && session.status() === "idle") || e.defaultPrevented) return + if ( + e.key !== "Escape" || + (!session.submitting() && session.status() === "idle" && !goal()?.active) || + e.defaultPrevented + ) + return e.preventDefault() session.abort() } @@ -231,8 +238,8 @@ export const ChatView: Component = (props) => { const hasActions = (hasChat: boolean) => canStartSession(hasChat) || canFork(hasChat) || canStartWorktree() || canMoveToWorktree(hasChat) - const renderActions = (hasChat: boolean) => ( - + const renderActions = (hasChat: boolean, control: () => JSX.Element) => ( +
@@ -347,6 +354,7 @@ export const ChatView: Component = (props) => { + {control()}
@@ -396,8 +404,9 @@ export const ChatView: Component = (props) => {
!props.readonly && hasActions(hasMessages())} - actions={() => renderActions(hasMessages())} + hasActions={() => !props.readonly && (hasActions(hasMessages()) || !!goal())} + actions={(control) => renderActions(hasMessages(), control)} + readonly={props.readonly} /> = (props) => { const git = useGitChangesContext(vscode, ctx, hasGit) const imageAttach = useImageAttachments() imageAttach.setFilePathDropHandler((paths) => { + if (readonly()) return const cwd = server.workspaceDirectory() const resolved = paths.map((p) => convertToMentionPath(p, cwd)) const ref = textareaRef @@ -273,7 +276,42 @@ export const PromptInput: Component = (props) => { pendingDraftKey(props.pendingSessionID ?? session.draftSessionID()) ?? "new" const draftKey = () => scopeDraftKey(boxKey(), rawKey()) + const goal = useGoalComposer(draftKey, { + send: (...args) => session.sendCommand(...args), + fingerprint: (key) => fingerprint(key), + clear: (key) => clearDraft(key), + }) + const fingerprint = (key: string) => + JSON.stringify( + key === draftKey() + ? [text().trim(), reviewComments(), imageAttach.images(), browsers()] + : [ + (drafts.get(key) ?? "").trim(), + reviewDrafts.get(key) ?? [], + imageDrafts.get(key) ?? [], + references.get(key) ?? [], + ], + ) const locked = () => !!props.edit && props.edit.sessionID === session.currentSessionID() + const readonly = () => locked() || (goal.active() && goal.pending()) + // Host-supplied drafts and attachments must wait, not disappear during Goal admission. + const deferred = new Map void)[]>() + let flushing = false + const defer = (key: string, work: (key: string) => void) => { + if (flushing || !goal.pending(key)) return false + deferred.set(key, [...(deferred.get(key) ?? []), work]) + return true + } + createEffect(() => { + const key = draftKey() + if (goal.pending(key)) return + queueMicrotask(() => { + if (draftKey() !== key || goal.pending(key)) return + const work = deferred.get(key) + deferred.delete(key) + work?.forEach((apply) => apply(key)) + }) + }) const saveDraft = ( key: string, next: string, @@ -365,6 +403,16 @@ export const PromptInput: Component = (props) => { undefined, undefined, [ + { + name: "goal", + description: language.t("prompt.goal.set"), + hints: [], + select: () => { + goal.activate() + ghost.dismiss() + textareaRef?.focus() + }, + }, { name: "update-from-base", description: "Ask the worktree agent to fetch and merge its saved base branch", @@ -439,10 +487,13 @@ export const PromptInput: Component = (props) => { references.set(draftKey(), next) } - const remove = (id: string) => replace(browsers().filter((item) => item.id !== id)) + const remove = (id: string) => { + if (!readonly()) replace(browsers().filter((item) => item.id !== id)) + } const clear = () => replace([]) const removeReviewComment = (id: string) => { + if (readonly()) return replaceReviewComments(reviewComments().filter((item) => item.id !== id)) } @@ -619,23 +670,31 @@ export const PromptInput: Component = (props) => { config(), globalConfig(), ) - const isDisabled = () => !server.isConnected() || locked() + const isDisabled = () => !server.isConnected() || locked() || goal.pending() const canUseSpeech = () => canUseSpeechToText(config(), provider.authStates()) const speechModel = () => selectedSpeechToTextModel(config(), speechModels.models()) const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0 || browsers().length > 0 - const sendReady = () => !isDisabled() && !terminal.pending() && !git.pending() && !props.blocked?.() - const canContinue = () => speech.state() === "idle" && !hasInput() && session.canResume() + const sendReady = () => !isDisabled() && goalReady() && !terminal.pending() && !git.pending() && !props.blocked?.() + const canContinue = () => !goal.active() && speech.state() === "idle" && !hasInput() && session.canResume() + const goalReady = () => !goal.pending() && (!goal.active() || (!enhancing() && !imageAttach.pending())) const canSend = () => - sendReady() && (speech.state() === "recording" || (!speech.active() && (hasInput() || canContinue()))) + sendReady() && + (speech.state() === "recording" || + (!speech.active() && (goal.active() ? goal.ready(text()) : hasInput() || canContinue()))) const canSendContinue = () => sendReady() && !speech.active() && canContinue() const sendLabel = () => { if (props.blocked?.()) return language.t("prompt.action.send.blocked") if (speech.state() === "recording") return language.t("prompt.action.send.recording") + if (goal.active()) return language.t("prompt.goal.start") if (canSendContinue()) return language.t("prompt.action.continue") return language.t("prompt.action.send") } - const showStop = () => isBusy() && !hasInput() && speech.state() !== "recording" + const showStop = () => + !goal.active() && + (isBusy() || session.currentSession()?.goal?.active) && + !hasInput() && + speech.state() !== "recording" const isAtEnd = () => textareaRef ? atEnd(textareaRef.selectionStart, textareaRef.selectionEnd, textareaRef.value.length) : false const highlightMentions = () => { @@ -798,7 +857,23 @@ export const PromptInput: Component = (props) => { }), }) - const restoreBox = (message: Extract) => { + const restoreBox = (message: Extract, key = draftKey()) => { + if (defer(key, (key) => restoreBox(message, key))) return + if (key !== draftKey()) { + savePromptDraft( + key, + message.text, + message.review ?? reviewDrafts.get(key) ?? [], + message.images?.map((image) => ({ ...image, id: crypto.randomUUID(), filename: image.filename ?? "image" })) ?? + imageDrafts.get(key) ?? + [], + scrollDrafts.get(key), + message.browser ?? references.get(key) ?? [], + ) + if (message.paths || message.sessions) + mentionDrafts.set(key, { paths: message.paths ?? [], sessions: message.sessions ?? [] }) + return + } setText(message.text) if (message.paths?.length) mention.seedFromParts(message.paths, message.text) else mention.seedFromText(message.text) @@ -823,7 +898,17 @@ export const PromptInput: Component = (props) => { } } - const appendBox = (message: Extract) => { + const appendBox = (message: Extract, key = draftKey()) => { + if (defer(key, (key) => appendBox(message, key))) return + if (key !== draftKey()) { + if (message.browser) { + references.set(key, mergeBrowserReferences(references.get(key) ?? [], message.browser)) + return + } + const current = drafts.get(key) ?? "" + drafts.set(key, current + (current && !current.endsWith("\n") ? "\n\n" : "") + message.text) + return + } const reference = message.browser if (reference) { if (reference.sessionId !== sid()) return @@ -844,14 +929,17 @@ export const PromptInput: Component = (props) => { } } - const appendReviews = (message: Extract) => { - const target = message.sessionID - ? promptDraftKey(boxKey(), message.sessionID, { - draft: props.pendingSessionID ?? session.draftSessionID(), - current: session.currentSessionID(), - }) - : draftKey() + const appendReviews = (message: Extract, key?: string) => { + const target = + key ?? + (message.sessionID + ? promptDraftKey(boxKey(), message.sessionID, { + draft: props.pendingSessionID ?? session.draftSessionID(), + current: session.currentSessionID(), + }) + : draftKey()) if (!target) return + if (defer(target, (key) => appendReviews(message, key))) return if (target !== draftKey()) { reviewDrafts.set(target, mergeReviewComments(reviewDrafts.get(target) ?? [], message.comments)) return @@ -871,6 +959,12 @@ export const PromptInput: Component = (props) => { if (!raw) return const source = scopeDraftKey(boxKey(), raw) const target = scopeDraftKey(boxKey(), sessionDraftKey(message.session.id)) + goal.move(source, target) + const queued = deferred.get(source) + if (queued) { + deferred.set(target, [...queued, ...(deferred.get(target) ?? [])]) + deferred.delete(source) + } if (source === draftKey()) saveDraft(source, text(), reviewComments(), imageAttach.images()) const from = reviewDrafts.get(source) const to = reviewDrafts.get(target) @@ -911,9 +1005,16 @@ export const PromptInput: Component = (props) => { } if (message.type === "sendMessageFailed") { + if (message.messageID && goal.finish(message.messageID, false)) { + return + } restoreFailed(message as SendMessageFailedMessage) } + if (message.type === "sessionCommandCompleted") { + goal.finish(message.messageID, true) + } + if (message.type === "sessionCreated") created(message) if (message.type === "action" && message.action === "focusInput") { @@ -942,6 +1043,7 @@ export const PromptInput: Component = (props) => { } if (message.type === "filePickerResult") { + if (defer(draftKey(), () => mention.insertFilePickerResult(message.path, message.requestId))) return mention.insertFilePickerResult(message.path, message.requestId) } }) @@ -949,6 +1051,10 @@ export const PromptInput: Component = (props) => { onCleanup(() => { props.onEditReady?.(false) + // Keep delayed host input in its draft even if the composer unmounts before acknowledgement. + flushing = true + for (const [key, work] of deferred) work.forEach((apply) => apply(key)) + deferred.clear() // Persist current draft before unmounting saveDraft(draftKey(), text(), reviewComments(), imageAttach.images()) if (sandboxRetry) clearTimeout(sandboxRetry) @@ -957,6 +1063,7 @@ export const PromptInput: Component = (props) => { }) const acceptSuggestion = () => { + if (readonly()) return const result = ghost.accept() if (!result) return @@ -999,7 +1106,7 @@ export const PromptInput: Component = (props) => { } const handlePaste = (e: ClipboardEvent) => { - if (locked()) { + if (readonly()) { e.preventDefault() return } @@ -1015,6 +1122,10 @@ export const PromptInput: Component = (props) => { const handleInput = (e: InputEvent) => { const target = e.target as HTMLTextAreaElement + if (readonly()) { + target.value = text() + return + } const val = target.value setText(val) preEnhanceText = null @@ -1022,13 +1133,29 @@ export const PromptInput: Component = (props) => { syncHighlightScroll() history.reset() - slash.onInput(val, target.selectionStart ?? val.length) + if (!goal.active()) slash.onInput(val, target.selectionStart ?? val.length) mention.onInput(val, target.selectionStart ?? val.length) ghost.setMentionOpen(slash.show() || mention.showMention()) ghost.scheduleRequest(val, textareaRef) } + const escape = (e: KeyboardEvent) => { + if (e.key !== "Escape") return false + if (hasPopup()) return true + if (!ghost.text() && !goal.active() && !isBusy()) return false + e.preventDefault() + e.stopPropagation() + if (ghost.text()) ghost.dismiss() + else if (goal.active()) goal.cancel() + else session.abort() + return true + } + const handleKeyDown = (e: KeyboardEvent) => { + if (goal.pending()) { + escape(e) + return + } if (locked()) return // Undo enhanced prompt with Ctrl+Z / ⌘Z if (e.key === "z" && (e.metaKey || e.ctrlKey) && !e.shiftKey && preEnhanceText !== null) { @@ -1112,18 +1239,7 @@ export const PromptInput: Component = (props) => { acceptSuggestion() return } - if (e.key === "Escape" && ghost.text()) { - e.preventDefault() - e.stopPropagation() - ghost.dismiss() - return - } - if (e.key === "Escape" && isBusy()) { - e.preventDefault() - e.stopPropagation() - session.abort() - return - } + if (escape(e)) return if (isEnterKeyCommitNotIme(e) && !e.shiftKey) { e.preventDefault() handleSend() @@ -1288,10 +1404,29 @@ export const PromptInput: Component = (props) => { } } + const command = (draft: string) => { + const match = draft.match(/^\/(\S+)/) + const word = match?.[1] + const entry = word + ? (slash.commands().find((c) => c.name === word) ?? slash.commands().find((c) => c.hints.includes(word))) + : undefined + return { match, entry } + } + const handleSend = async () => { const draft = text().trim() + if ( + !goal.prepare(draft, () => { + setText("") + slash.close() + ghost.dismiss() + adjustHeight() + }) + ) + return + const objective = goal.active() - const memory = parseMemoryCommand(draft) + const memory = objective ? undefined : parseMemoryCommand(draft) if (memory) { if (!runMemory(memory)) return history.append(draft) @@ -1313,11 +1448,9 @@ export const PromptInput: Component = (props) => { // Detect slash command (hoisted for both client and server command checks). // Prioritize exact name matches over hint/alias matches so that a server // command named e.g. "continue" is not hijacked by a client alias. - const cmdMatch = draft.match(/^\/(\S+)/) - const word = cmdMatch?.[1] - const matched = word - ? (slash.commands().find((c) => c.name === word) ?? slash.commands().find((c) => c.hints.includes(word))) - : undefined + const parsed = command(objective ? "" : draft) + const cmdMatch = parsed.match + const matched = parsed.entry // Client-side slash command — runs locally without a backend round-trip if (matched?.action) { @@ -1360,6 +1493,7 @@ export const PromptInput: Component = (props) => { const sel = session.selected(id) const context = ctx() const key = draftKey() + const stamp = fingerprint(key) const terminalFile = await terminal .resolveAttachment(message, id, readTerminalContext(props.terminalContext)) @@ -1394,6 +1528,23 @@ export const PromptInput: Component = (props) => { ] const attachments = allFiles.length > 0 ? allFiles : undefined + if (objective) { + mention.closeMention() + slash.close() + ghost.dismiss() + goal.send(key, stamp, [ + "goal", + `-- ${message}`, + sel?.providerID, + sel?.modelID, + attachments, + pendingId, + context, + origin ?? null, + ]) + return + } + // Server-side slash command (cmdMatch/matched already computed above) if (matched && !data && !browserData) { const args = draft.slice(cmdMatch![0].length).trim() @@ -1428,13 +1579,17 @@ export const PromptInput: Component = (props) => { if (!accepted) return } + clearDraft(key, draft) + } + + const clearDraft = (key: string, value = key === draftKey() ? text().trim() : (drafts.get(key) ?? "").trim()) => { + history.append(value) drafts.delete(key) reviewDrafts.delete(key) references.delete(key) imageDrafts.delete(key) mentionDrafts.delete(key) scrollDrafts.delete(key) - history.append(draft) if (draftKey() !== key) return history.reset() @@ -1455,24 +1610,40 @@ export const PromptInput: Component = (props) => { onDragOver={imageAttach.handleDragOver} onDragLeave={imageAttach.handleDragLeave} onDrop={(event) => { - if (locked()) { + if (readonly()) { event.preventDefault() return } imageAttach.handleDrop(event) }} > + + { + goal.cancel() + textareaRef?.focus() + }} + /> + 0}> replaceReviewComments(reviewComments().filter((item) => !ids.includes(item.id)))} + onClear={(ids) => { + if (!readonly()) replaceReviewComments(reviewComments().filter((item) => !ids.includes(item.id))) + }} /> 0}>
- + { + if (!readonly()) clear() + }} + />
@@ -1614,7 +1785,10 @@ export const PromptInput: Component = (props) => {