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/pages/session.tsx | 159 ++++++++++++++++++++- .../session/composer/session-composer-region.tsx | 26 ++++ .../session/composer/session-followup-dock.tsx | 109 ++++++++++++++ .../pages/session/composer/session-revert-dock.tsx | 4 +- .../app/src/pages/session/message-timeline.tsx | 7 - 5 files changed, 295 insertions(+), 10 deletions(-) create mode 100644 packages/app/src/pages/session/composer/session-followup-dock.tsx (limited to 'packages/app/src/pages') 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, + sending: {} as Record, + failed: {} as Record, + paused: {} as Record, + 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`, }} > + + + 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 ( + +
{ + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + toggle() + }} + > + {label()} + + {preview()} + +
+ { + event.preventDefault() + event.stopPropagation() + }} + onClick={(event) => { + event.stopPropagation() + toggle() + }} + aria-label={ + store.collapsed ? language.t("session.followupDock.expand") : language.t("session.followupDock.collapse") + } + /> +
+
+ + +