diff options
| author | Adam <[email protected]> | 2026-03-12 15:17:36 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-12 20:17:36 +0000 |
| commit | 42a5af6c8f6998277cf69270ad12e2a64edac5d3 (patch) | |
| tree | eca5dff51dc694cce1e783425fc11b4bea1e6a12 /packages/app/src/components | |
| parent | f0542fae7a917fabb9e943c3112a3d0b4b03302d (diff) | |
| download | opencode-42a5af6c8f6998277cf69270ad12e2a64edac5d3.tar.gz opencode-42a5af6c8f6998277cf69270ad12e2a64edac5d3.zip | |
feat(app): follow-up behavior (#17233)
Diffstat (limited to 'packages/app/src/components')
| -rw-r--r-- | packages/app/src/components/prompt-input.tsx | 49 | ||||
| -rw-r--r-- | packages/app/src/components/prompt-input/submit.ts | 229 | ||||
| -rw-r--r-- | packages/app/src/components/settings-general.tsx | 130 |
3 files changed, 305 insertions, 103 deletions
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<PromptInputProps> = (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<PromptInputProps> = (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<string, PendingPrompt>() +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<typeof useSDK>["client"] + globalSync: ReturnType<typeof useGlobalSync> + sync: ReturnType<typeof useSync> + draft: FollowupDraft + messageID?: string + optimisticBusy?: boolean + before?: () => Promise<boolean> | 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<ImageAttachmentPart[]> @@ -41,6 +180,9 @@ type PromptSubmitInput = { setPopover: (popover: "at" | "slash" | null) => void newSessionWorktree?: Accessor<string | undefined> onNewSessionWorktreeReset?: () => void + shouldQueue?: Accessor<boolean> + 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 = () => ( <div class="flex flex-col gap-1"> - <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3> - <div class="bg-surface-raised-base px-4 rounded-lg"> <SettingsRow title={language.t("settings.general.row.language.title")} @@ -193,8 +196,70 @@ export const SettingsGeneral: Component = () => { </SettingsRow> <SettingsRow - title={language.t("settings.general.row.appearance.title")} - description={language.t("settings.general.row.appearance.description")} + title={language.t("settings.general.row.reasoningSummaries.title")} + description={language.t("settings.general.row.reasoningSummaries.description")} + > + <div data-action="settings-feed-reasoning-summaries"> + <Switch + checked={settings.general.showReasoningSummaries()} + onChange={(checked) => settings.general.setShowReasoningSummaries(checked)} + /> + </div> + </SettingsRow> + + <SettingsRow + title={language.t("settings.general.row.shellToolPartsExpanded.title")} + description={language.t("settings.general.row.shellToolPartsExpanded.description")} + > + <div data-action="settings-feed-shell-tool-parts-expanded"> + <Switch + checked={settings.general.shellToolPartsExpanded()} + onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)} + /> + </div> + </SettingsRow> + + <SettingsRow + title={language.t("settings.general.row.editToolPartsExpanded.title")} + description={language.t("settings.general.row.editToolPartsExpanded.description")} + > + <div data-action="settings-feed-edit-tool-parts-expanded"> + <Switch + checked={settings.general.editToolPartsExpanded()} + onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)} + /> + </div> + </SettingsRow> + + <SettingsRow + title={language.t("settings.general.row.followup.title")} + description={language.t("settings.general.row.followup.description")} + > + <Select + data-action="settings-followup" + options={followupOptions()} + current={followupOptions().find((o) => o.value === settings.general.followup())} + value={(o) => o.value} + label={(o) => o.label} + onSelect={(option) => option && settings.general.setFollowup(option.value)} + variant="secondary" + size="small" + triggerVariant="settings" + triggerStyle={{ "min-width": "180px" }} + /> + </SettingsRow> + </div> + </div> + ) + + const AppearanceSection = () => ( + <div class="flex flex-col gap-1"> + <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3> + + <div class="bg-surface-raised-base px-4 rounded-lg"> + <SettingsRow + title={language.t("settings.general.row.colorScheme.title")} + description={language.t("settings.general.row.colorScheme.description")} > <Select data-action="settings-color-scheme" @@ -211,6 +276,7 @@ export const SettingsGeneral: Component = () => { variant="secondary" size="small" triggerVariant="settings" + triggerStyle={{ "min-width": "220px" }} /> </SettingsRow> @@ -271,50 +337,6 @@ export const SettingsGeneral: Component = () => { </div> ) - const FeedSection = () => ( - <div class="flex flex-col gap-1"> - <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.feed")}</h3> - - <div class="bg-surface-raised-base px-4 rounded-lg"> - <SettingsRow - title={language.t("settings.general.row.reasoningSummaries.title")} - description={language.t("settings.general.row.reasoningSummaries.description")} - > - <div data-action="settings-feed-reasoning-summaries"> - <Switch - checked={settings.general.showReasoningSummaries()} - onChange={(checked) => settings.general.setShowReasoningSummaries(checked)} - /> - </div> - </SettingsRow> - - <SettingsRow - title={language.t("settings.general.row.shellToolPartsExpanded.title")} - description={language.t("settings.general.row.shellToolPartsExpanded.description")} - > - <div data-action="settings-feed-shell-tool-parts-expanded"> - <Switch - checked={settings.general.shellToolPartsExpanded()} - onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)} - /> - </div> - </SettingsRow> - - <SettingsRow - title={language.t("settings.general.row.editToolPartsExpanded.title")} - description={language.t("settings.general.row.editToolPartsExpanded.description")} - > - <div data-action="settings-feed-edit-tool-parts-expanded"> - <Switch - checked={settings.general.editToolPartsExpanded()} - onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)} - /> - </div> - </SettingsRow> - </div> - </div> - ) - const NotificationsSection = () => ( <div class="flex flex-col gap-1"> <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3> @@ -465,9 +487,9 @@ export const SettingsGeneral: Component = () => { </div> <div class="flex flex-col gap-8 w-full"> - <AppearanceSection /> + <GeneralSection /> - <FeedSection /> + <AppearanceSection /> <NotificationsSection /> @@ -551,12 +573,12 @@ interface SettingsRowProps { const SettingsRow: Component<SettingsRowProps> = (props) => { return ( - <div class="flex flex-wrap items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none"> - <div class="flex flex-col gap-0.5 min-w-0"> + <div class="flex flex-wrap items-center gap-4 py-3 border-b border-border-weak-base last:border-none sm:flex-nowrap"> + <div class="flex min-w-0 flex-1 flex-col gap-0.5"> <span class="text-14-medium text-text-strong">{props.title}</span> <span class="text-12-regular text-text-weak">{props.description}</span> </div> - <div class="flex-shrink-0">{props.children}</div> + <div class="flex w-full justify-end sm:w-auto sm:shrink-0">{props.children}</div> </div> ) } |
