From 42a5af6c8f6998277cf69270ad12e2a64edac5d3 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:17:36 -0500 Subject: feat(app): follow-up behavior (#17233) --- packages/app/src/components/prompt-input.tsx | 49 ++++- packages/app/src/components/prompt-input/submit.ts | 229 ++++++++++++++++----- packages/app/src/components/settings-general.tsx | 130 +++++++----- 3 files changed, 305 insertions(+), 103 deletions(-) (limited to 'packages/app/src/components') diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index af9c7530f..ac5beed69 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -48,7 +48,7 @@ import { type PromptHistoryStoredEntry, promptLength, } from "./prompt-input/history" -import { createPromptSubmit } from "./prompt-input/submit" +import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit" import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover" import { PromptContextItems } from "./prompt-input/context-items" import { PromptImageAttachments } from "./prompt-input/image-attachments" @@ -61,6 +61,11 @@ interface PromptInputProps { ref?: (el: HTMLDivElement) => void newSessionWorktree?: string onNewSessionWorktreeReset?: () => void + edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] } + onEditLoaded?: () => void + shouldQueue?: () => boolean + onQueue?: (draft: FollowupDraft) => void + onAbort?: () => void onSubmit?: () => void } @@ -947,6 +952,45 @@ export const PromptInput: Component = (props) => { setCurrentHistory("entries", next) } + createEffect( + on( + () => props.edit?.id, + (id) => { + const edit = props.edit + if (!id || !edit) return + + for (const item of prompt.context.items()) { + prompt.context.remove(item.key) + } + + for (const item of edit.context) { + prompt.context.add({ + type: item.type, + path: item.path, + selection: item.selection, + comment: item.comment, + commentID: item.commentID, + commentOrigin: item.commentOrigin, + preview: item.preview, + }) + } + + setStore("mode", "normal") + setStore("popover", null) + setStore("historyIndex", -1) + setStore("savedPrompt", null) + prompt.set(edit.prompt, promptLength(edit.prompt)) + requestAnimationFrame(() => { + editorRef.focus() + setCursorPosition(editorRef, promptLength(edit.prompt)) + queueScroll() + }) + props.onEditLoaded?.() + }, + { defer: true }, + ), + ) + const navigateHistory = (direction: "up" | "down") => { const result = navigatePromptHistory({ direction, @@ -1001,6 +1045,9 @@ export const PromptInput: Component = (props) => { setPopover: (popover) => setStore("popover", popover), newSessionWorktree: () => props.newSessionWorktree, onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, + shouldQueue: props.shouldQueue, + onQueue: props.onQueue, + onAbort: props.onAbort, onSubmit: props.onSubmit, }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index fee6b070d..eb3e0c82f 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -9,7 +9,7 @@ import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useLocal } from "@/context/local" import { usePermission } from "@/context/permission" -import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" +import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { Identifier } from "@/utils/id" @@ -25,6 +25,145 @@ type PendingPrompt = { const pending = new Map() +export type FollowupDraft = { + sessionID: string + sessionDirectory: string + prompt: Prompt + context: (ContextItem & { key: string })[] + agent: string + model: { providerID: string; modelID: string } + variant?: string +} + +type FollowupSendInput = { + client: ReturnType["client"] + globalSync: ReturnType + sync: ReturnType + draft: FollowupDraft + messageID?: string + optimisticBusy?: boolean + before?: () => Promise | boolean +} + +const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("") + +const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image") + +export async function sendFollowupDraft(input: FollowupSendInput) { + const text = draftText(input.draft.prompt) + const images = draftImages(input.draft.prompt) + const [, setStore] = input.globalSync.child(input.draft.sessionDirectory) + + const setBusy = () => { + if (!input.optimisticBusy) return + setStore("session_status", input.draft.sessionID, { type: "busy" }) + } + + const setIdle = () => { + if (!input.optimisticBusy) return + setStore("session_status", input.draft.sessionID, { type: "idle" }) + } + + const wait = async () => { + const ok = await input.before?.() + if (ok === false) return false + return true + } + + const [head, ...tail] = text.split(" ") + const cmd = head?.startsWith("/") ? head.slice(1) : undefined + if (cmd && input.sync.data.command.find((item) => item.name === cmd)) { + setBusy() + try { + if (!(await wait())) { + setIdle() + return false + } + + await input.client.session.command({ + sessionID: input.draft.sessionID, + command: cmd, + arguments: tail.join(" "), + agent: input.draft.agent, + model: `${input.draft.model.providerID}/${input.draft.model.modelID}`, + variant: input.draft.variant, + parts: images.map((attachment) => ({ + id: Identifier.ascending("part"), + type: "file" as const, + mime: attachment.mime, + url: attachment.dataUrl, + filename: attachment.filename, + })), + }) + return true + } catch (err) { + setIdle() + throw err + } + } + + const messageID = input.messageID ?? Identifier.ascending("message") + const { requestParts, optimisticParts } = buildRequestParts({ + prompt: input.draft.prompt, + context: input.draft.context, + images, + text, + sessionID: input.draft.sessionID, + messageID, + sessionDirectory: input.draft.sessionDirectory, + }) + + const message: Message = { + id: messageID, + sessionID: input.draft.sessionID, + role: "user", + time: { created: Date.now() }, + agent: input.draft.agent, + model: input.draft.model, + variant: input.draft.variant, + } + + const add = () => + input.sync.session.optimistic.add({ + directory: input.draft.sessionDirectory, + sessionID: input.draft.sessionID, + message, + parts: optimisticParts, + }) + + const remove = () => + input.sync.session.optimistic.remove({ + directory: input.draft.sessionDirectory, + sessionID: input.draft.sessionID, + messageID, + }) + + setBusy() + add() + + try { + if (!(await wait())) { + setIdle() + remove() + return false + } + + await input.client.session.promptAsync({ + sessionID: input.draft.sessionID, + agent: input.draft.agent, + model: input.draft.model, + messageID, + parts: requestParts, + variant: input.draft.variant, + }) + return true + } catch (err) { + setIdle() + remove() + throw err + } +} + type PromptSubmitInput = { info: Accessor<{ id: string } | undefined> imageAttachments: Accessor @@ -41,6 +180,9 @@ type PromptSubmitInput = { setPopover: (popover: "at" | "slash" | null) => void newSessionWorktree?: Accessor onNewSessionWorktreeReset?: () => void + shouldQueue?: Accessor + onQueue?: (draft: FollowupDraft) => void + onAbort?: () => void onSubmit?: () => void } @@ -82,6 +224,8 @@ export function createPromptSubmit(input: PromptSubmitInput) { const [, setStore] = globalSync.child(sdk.directory) setStore("todo", sessionID, []) + input.onAbort?.() + const queued = pending.get(sessionID) if (queued) { queued.abort.abort() @@ -116,6 +260,12 @@ export function createPromptSubmit(input: PromptSubmitInput) { } } + const clearContext = () => { + for (const item of prompt.context.items()) { + prompt.context.remove(item.key) + } + } + const handleSubmit = async (event: Event) => { event.preventDefault() @@ -215,14 +365,22 @@ export function createPromptSubmit(input: PromptSubmitInput) { return } - input.onSubmit?.() - const model = { modelID: currentModel.id, providerID: currentModel.provider.id, } const agent = currentAgent.name const variant = local.model.variant.current() + const context = prompt.context.items().slice() + const draft: FollowupDraft = { + sessionID: session.id, + sessionDirectory, + prompt: currentPrompt, + context, + agent, + model, + variant, + } const clearInput = () => { prompt.reset() @@ -243,6 +401,15 @@ export function createPromptSubmit(input: PromptSubmitInput) { }) } + if (!isNewSession && mode === "normal" && input.shouldQueue?.()) { + input.onQueue?.(draft) + clearContext() + clearInput() + return + } + + input.onSubmit?.() + if (mode === "shell") { clearInput() client.session @@ -295,48 +462,19 @@ export function createPromptSubmit(input: PromptSubmitInput) { } } - const context = prompt.context.items().slice() const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) - const messageID = Identifier.ascending("message") - const { requestParts, optimisticParts } = buildRequestParts({ - prompt: currentPrompt, - context, - images, - text, - sessionID: session.id, - messageID, - sessionDirectory, - }) - - const optimisticMessage: Message = { - id: messageID, - sessionID: session.id, - role: "user", - time: { created: Date.now() }, - agent, - model, - variant, - } - - const addOptimisticMessage = () => - sync.session.optimistic.add({ - directory: sessionDirectory, - sessionID: session.id, - message: optimisticMessage, - parts: optimisticParts, - }) - const removeOptimisticMessage = () => + const removeOptimisticMessage = () => { sync.session.optimistic.remove({ directory: sessionDirectory, sessionID: session.id, messageID, }) + } removeCommentItems(commentItems) clearInput() - addOptimisticMessage() const waitForWorktree = async () => { const worktree = WorktreeState.get(sessionDirectory) @@ -393,20 +531,15 @@ export function createPromptSubmit(input: PromptSubmitInput) { return true } - const send = async () => { - const ok = await waitForWorktree() - if (!ok) return - await client.session.promptAsync({ - sessionID: session.id, - agent, - model, - messageID, - parts: requestParts, - variant, - }) - } - - void send().catch((err) => { + void sendFollowupDraft({ + client, + sync, + globalSync, + draft, + messageID, + optimisticBusy: sessionDirectory === projectDirectory, + before: waitForWorktree, + }).catch((err) => { pending.delete(session.id) if (sessionDirectory === projectDirectory) { sync.set("session_status", session.id, { type: "idle" }) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 42ee4092f..16689bfe2 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -113,6 +113,11 @@ export const SettingsGeneral: Component = () => { { value: "dark", label: language.t("theme.scheme.dark") }, ]) + const followupOptions = createMemo((): { value: "queue" | "steer"; label: string }[] => [ + { value: "queue", label: language.t("settings.general.row.followup.option.queue") }, + { value: "steer", label: language.t("settings.general.row.followup.option.steer") }, + ]) + const languageOptions = createMemo(() => language.locales.map((locale) => ({ value: locale, @@ -170,10 +175,8 @@ export const SettingsGeneral: Component = () => { triggerVariant: "settings" as const, }) - const AppearanceSection = () => ( + const GeneralSection = () => (
-

{language.t("settings.general.section.appearance")}

-
{ +
+ settings.general.setShowReasoningSummaries(checked)} + /> +
+
+ + +
+ settings.general.setShellToolPartsExpanded(checked)} + /> +
+
+ + +
+ settings.general.setEditToolPartsExpanded(checked)} + /> +
+
+ + + { variant="secondary" size="small" triggerVariant="settings" + triggerStyle={{ "min-width": "220px" }} /> @@ -271,50 +337,6 @@ export const SettingsGeneral: Component = () => {
) - const FeedSection = () => ( -
-

{language.t("settings.general.section.feed")}

- -
- -
- settings.general.setShowReasoningSummaries(checked)} - /> -
-
- - -
- settings.general.setShellToolPartsExpanded(checked)} - /> -
-
- - -
- settings.general.setEditToolPartsExpanded(checked)} - /> -
-
-
-
- ) - const NotificationsSection = () => (

{language.t("settings.general.section.notifications")}

@@ -465,9 +487,9 @@ export const SettingsGeneral: Component = () => {
- + - + @@ -551,12 +573,12 @@ interface SettingsRowProps { const SettingsRow: Component = (props) => { return ( -
-
+
+
{props.title} {props.description}
-
{props.children}
+
{props.children}
) } -- cgit v1.2.3