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/pages | |
| parent | f0542fae7a917fabb9e943c3112a3d0b4b03302d (diff) | |
| download | opencode-42a5af6c8f6998277cf69270ad12e2a64edac5d3.tar.gz opencode-42a5af6c8f6998277cf69270ad12e2a64edac5d3.zip | |
feat(app): follow-up behavior (#17233)
Diffstat (limited to 'packages/app/src/pages')
5 files changed, 295 insertions, 10 deletions
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index b8d32fed4..7a8318374 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -35,8 +35,10 @@ import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" +import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" +import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers" import { MessageTimeline } from "@/pages/session/message-timeline" @@ -47,11 +49,13 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel" import { TerminalPanel } from "@/pages/session/terminal-panel" import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" +import { Identifier } from "@/utils/id" import { extractPromptFromParts } from "@/utils/prompt" import { same } from "@/utils/same" import { formatServerError } from "@/utils/server-errors" const emptyUserMessages: UserMessage[] = [] +const emptyFollowups: (FollowupDraft & { id: string })[] = [] type SessionHistoryWindowInput = { sessionID: () => string | undefined @@ -270,6 +274,7 @@ export default function Page() { const language = useLanguage() const navigate = useNavigate() const sdk = useSDK() + const settings = useSettings() const prompt = usePrompt() const comments = useComments() const terminal = useTerminal() @@ -466,6 +471,17 @@ export default function Page() { deferRender: false, }) + const [followup, setFollowup] = createStore({ + items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>, + sending: {} as Record<string, string | undefined>, + failed: {} as Record<string, string | undefined>, + paused: {} as Record<string, boolean | undefined>, + edit: {} as Record< + string, + { id: string; prompt: FollowupDraft["prompt"]; context: FollowupDraft["context"] } | undefined + >, + }) + createComputed((prev) => { const key = sessionKey() if (key !== prev) { @@ -1264,12 +1280,117 @@ export default function Page() { }) const busy = (sessionID: string) => { - if (sync.data.session_status[sessionID]?.type !== "idle") return true + if ((sync.data.session_status[sessionID] ?? { type: "idle" as const }).type !== "idle") return true return (sync.data.message[sessionID] ?? []).some( (item) => item.role === "assistant" && typeof item.time.completed !== "number", ) } + const queuedFollowups = createMemo(() => { + const id = params.id + if (!id) return emptyFollowups + return followup.items[id] ?? emptyFollowups + }) + + const editingFollowup = createMemo(() => { + const id = params.id + if (!id) return + return followup.edit[id] + }) + + const sendingFollowup = createMemo(() => { + const id = params.id + if (!id) return + return followup.sending[id] + }) + + const queueEnabled = createMemo(() => { + const id = params.id + if (!id) return false + return settings.general.followup() === "queue" && busy(id) && !composer.blocked() + }) + + const followupText = (item: FollowupDraft) => { + const text = item.prompt + .map((part) => { + if (part.type === "image") return `[image:${part.filename}]` + if (part.type === "file") return `[file:${part.path}]` + if (part.type === "agent") return `@${part.name}` + return part.content + }) + .join("") + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => !!line) + + if (text) return text + return `[${language.t("common.attachment")}]` + } + + const queueFollowup = (draft: FollowupDraft) => { + setFollowup("items", draft.sessionID, (items) => [ + ...(items ?? []), + { id: Identifier.ascending("message"), ...draft }, + ]) + setFollowup("failed", draft.sessionID, undefined) + setFollowup("paused", draft.sessionID, undefined) + } + + const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) }))) + + const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => { + const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id) + if (!item) return Promise.resolve() + if (followup.sending[sessionID]) return Promise.resolve() + + if (opts?.manual) setFollowup("paused", sessionID, undefined) + setFollowup("sending", sessionID, id) + setFollowup("failed", sessionID, undefined) + + return sendFollowupDraft({ + client: sdk.client, + sync, + globalSync, + draft: item, + optimisticBusy: item.sessionDirectory === sdk.directory, + }) + .then((ok) => { + if (ok === false) return + setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id)) + if (opts?.manual) resumeScroll() + }) + .catch((err) => { + setFollowup("failed", sessionID, id) + fail(err) + }) + .finally(() => { + setFollowup("sending", sessionID, (value) => (value === id ? undefined : value)) + }) + } + + const editFollowup = (id: string) => { + const sessionID = params.id + if (!sessionID) return + if (followup.sending[sessionID]) return + + const item = queuedFollowups().find((entry) => entry.id === id) + if (!item) return + + setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id)) + setFollowup("failed", sessionID, (value) => (value === id ? undefined : value)) + setFollowup("edit", sessionID, { + id: item.id, + prompt: item.prompt, + context: item.context, + }) + } + + const clearFollowupEdit = () => { + const id = params.id + if (!id) return + setFollowup("edit", id, undefined) + } + const halt = (sessionID: string) => busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve() @@ -1378,6 +1499,21 @@ export default function Page() { const actions = { fork, revert } + createEffect(() => { + const sessionID = params.id + if (!sessionID) return + + const item = queuedFollowups()[0] + if (!item) return + if (followup.sending[sessionID]) return + if (followup.failed[sessionID] === item.id) return + if (followup.paused[sessionID]) return + if (composer.blocked()) return + if (busy(sessionID)) return + + void sendFollowup(sessionID, item.id) + }) + createResizeObserver( () => promptDock, ({ height }) => { @@ -1537,6 +1673,27 @@ export default function Page() { resumeScroll() }} onResponseSubmit={resumeScroll} + followup={ + params.id + ? { + queue: queueEnabled, + items: followupDock(), + sending: sendingFollowup(), + edit: editingFollowup(), + onQueue: queueFollowup, + onAbort: () => { + const id = params.id + if (!id) return + setFollowup("paused", id, true) + }, + onSend: (id) => { + void sendFollowup(params.id!, id, { manual: true }) + }, + onEdit: editFollowup, + onEditLoaded: clearFollowupEdit, + } + : undefined + } revert={ rolled().length > 0 ? { diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 0cca90180..2034fbead 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -8,9 +8,11 @@ import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" import { useSessionKey } from "@/pages/session/session-layout" import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock" import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock" +import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock" import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock" import type { SessionComposerState } from "@/pages/session/composer/session-composer-state" import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" +import type { FollowupDraft } from "@/components/prompt-input/submit" export function SessionComposerRegion(props: { state: SessionComposerState @@ -21,6 +23,17 @@ export function SessionComposerRegion(props: { onNewSessionWorktreeReset: () => void onSubmit: () => void onResponseSubmit: () => void + followup?: { + queue: () => boolean + items: { id: string; text: string }[] + sending?: string + edit?: { id: string; prompt: FollowupDraft["prompt"]; context: FollowupDraft["context"] } + onQueue: (draft: FollowupDraft) => void + onAbort: () => void + onSend: (id: string) => void + onEdit: (id: string) => void + onEditLoaded: () => void + } revert?: { items: { id: string; text: string }[] restoring?: string @@ -214,10 +227,23 @@ export function SessionComposerRegion(props: { "margin-top": `${-lift()}px`, }} > + <Show when={props.followup?.items.length}> + <SessionFollowupDock + items={props.followup!.items} + sending={props.followup!.sending} + onSend={props.followup!.onSend} + onEdit={props.followup!.onEdit} + /> + </Show> <PromptInput ref={props.inputRef} newSessionWorktree={props.newSessionWorktree} onNewSessionWorktreeReset={props.onNewSessionWorktreeReset} + edit={props.followup?.edit} + onEditLoaded={props.followup?.onEditLoaded} + shouldQueue={props.followup?.queue} + onQueue={props.followup?.onQueue} + onAbort={props.followup?.onAbort} onSubmit={props.onSubmit} /> </div> diff --git a/packages/app/src/pages/session/composer/session-followup-dock.tsx b/packages/app/src/pages/session/composer/session-followup-dock.tsx new file mode 100644 index 000000000..7d744f4e6 --- /dev/null +++ b/packages/app/src/pages/session/composer/session-followup-dock.tsx @@ -0,0 +1,109 @@ +import { For, Show, createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { Button } from "@opencode-ai/ui/button" +import { DockTray } from "@opencode-ai/ui/dock-surface" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { useLanguage } from "@/context/language" + +export function SessionFollowupDock(props: { + items: { id: string; text: string }[] + sending?: string + onSend: (id: string) => void + onEdit: (id: string) => void +}) { + const language = useLanguage() + const [store, setStore] = createStore({ + collapsed: false, + }) + + const toggle = () => setStore("collapsed", (value) => !value) + const total = createMemo(() => props.items.length) + const label = createMemo(() => + language.t(total() === 1 ? "session.followupDock.summary.one" : "session.followupDock.summary.other", { + count: total(), + }), + ) + const preview = createMemo(() => props.items[0]?.text ?? "") + + return ( + <DockTray + data-component="session-followup-dock" + style={{ + "margin-bottom": "-0.875rem", + "border-bottom-left-radius": 0, + "border-bottom-right-radius": 0, + }} + > + <div + class="pl-3 pr-2 py-2 flex items-center gap-2" + role="button" + tabIndex={0} + onClick={toggle} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + toggle() + }} + > + <span class="shrink-0 text-13-medium text-text-strong cursor-default">{label()}</span> + <Show when={store.collapsed && preview()}> + <span class="min-w-0 flex-1 truncate text-13-regular text-text-base cursor-default">{preview()}</span> + </Show> + <div class="ml-auto shrink-0"> + <IconButton + data-collapsed={store.collapsed ? "true" : "false"} + icon="chevron-down" + size="normal" + variant="ghost" + style={{ transform: `rotate(${store.collapsed ? 180 : 0}deg)` }} + onMouseDown={(event) => { + event.preventDefault() + event.stopPropagation() + }} + onClick={(event) => { + event.stopPropagation() + toggle() + }} + aria-label={ + store.collapsed ? language.t("session.followupDock.expand") : language.t("session.followupDock.collapse") + } + /> + </div> + </div> + + <Show when={store.collapsed}> + <div class="h-5" aria-hidden="true" /> + </Show> + + <Show when={!store.collapsed}> + <div class="px-3 pb-7 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar"> + <For each={props.items}> + {(item) => ( + <div class="flex items-center gap-2 min-w-0 py-1"> + <span class="min-w-0 flex-1 truncate text-13-regular text-text-strong">{item.text}</span> + <Button + size="small" + variant="secondary" + class="shrink-0" + disabled={!!props.sending} + onClick={() => props.onSend(item.id)} + > + {language.t("session.followupDock.sendNow")} + </Button> + <Button + size="small" + variant="ghost" + class="shrink-0" + disabled={!!props.sending} + onClick={() => props.onEdit(item.id)} + > + {language.t("session.followupDock.edit")} + </Button> + </div> + )} + </For> + </div> + </Show> + </DockTray> + ) +} diff --git a/packages/app/src/pages/session/composer/session-revert-dock.tsx b/packages/app/src/pages/session/composer/session-revert-dock.tsx index fd4b39ee7..5a5b8a9cd 100644 --- a/packages/app/src/pages/session/composer/session-revert-dock.tsx +++ b/packages/app/src/pages/session/composer/session-revert-dock.tsx @@ -75,10 +75,10 @@ export function SessionRevertDock(props: { </Show> <Show when={!store.collapsed}> - <div class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar"> + <div class="px-3 pb-7 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar"> <For each={props.items}> {(item) => ( - <div class="flex items-center gap-2 min-w-0 rounded-[10px] border border-border-weak-base bg-background-stronger px-2.5 py-2"> + <div class="flex items-center gap-2 min-w-0 py-1"> <span class="min-w-0 flex-1 truncate text-13-regular text-text-strong">{item.text}</span> <Button size="small" diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index ad4ef8443..b4a740c60 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -942,12 +942,6 @@ export function MessageTimeline(props: { <For each={rendered()}> {(messageID) => { const active = createMemo(() => activeMessageID() === messageID) - const queued = createMemo(() => { - if (active()) return false - const activeID = activeMessageID() - if (activeID) return messageID > activeID - return false - }) const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], { equals: (a, b) => JSON.stringify(a) === JSON.stringify(b), }) @@ -1007,7 +1001,6 @@ export function MessageTimeline(props: { messageID={messageID} actions={props.actions} active={active()} - queued={queued()} status={active() ? sessionStatus() : undefined} showReasoningSummaries={settings.general.showReasoningSummaries()} shellToolDefaultOpen={settings.general.shellToolPartsExpanded()} |
