summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/pages
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/pages
parentf0542fae7a917fabb9e943c3112a3d0b4b03302d (diff)
downloadopencode-42a5af6c8f6998277cf69270ad12e2a64edac5d3.tar.gz
opencode-42a5af6c8f6998277cf69270ad12e2a64edac5d3.zip
feat(app): follow-up behavior (#17233)
Diffstat (limited to 'packages/app/src/pages')
-rw-r--r--packages/app/src/pages/session.tsx159
-rw-r--r--packages/app/src/pages/session/composer/session-composer-region.tsx26
-rw-r--r--packages/app/src/pages/session/composer/session-followup-dock.tsx109
-rw-r--r--packages/app/src/pages/session/composer/session-revert-dock.tsx4
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx7
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()}