summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-03-12 15:17:36 -0500
committerGitHub <[email protected]>2026-03-12 20:17:36 +0000
commit42a5af6c8f6998277cf69270ad12e2a64edac5d3 (patch)
treeeca5dff51dc694cce1e783425fc11b4bea1e6a12 /packages/app/src/components
parentf0542fae7a917fabb9e943c3112a3d0b4b03302d (diff)
downloadopencode-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.tsx49
-rw-r--r--packages/app/src/components/prompt-input/submit.ts229
-rw-r--r--packages/app/src/components/settings-general.tsx130
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>
)
}