From dd780ca88208f852677ed0749261199d4759c74b Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:59:51 +0800 Subject: [PATCH] fix(app): hand off optimistic attachments (#44411) --- packages/app/src/composer/adapter.ts | 5 ++ packages/app/src/composer/submit.test.ts | 49 +++++++++++++++++++ packages/app/src/composer/submit.ts | 26 ++++++++++ .../app/src/new-session/composer-adapter.ts | 32 +++++++++++- packages/app/src/session/handoff.test.ts | 18 +++++++ packages/app/src/session/handoff.ts | 24 +++++++++ .../timeline/controller-projection.test.ts | 36 +++++++++++++- .../session/timeline/controller-projection.ts | 11 ++++- .../app/src/session/timeline/controller.tsx | 16 +++++- 9 files changed, 211 insertions(+), 6 deletions(-) create mode 100644 packages/app/src/session/handoff.test.ts diff --git a/packages/app/src/composer/adapter.ts b/packages/app/src/composer/adapter.ts index a84e7f42caf..a663eef3b4b 100644 --- a/packages/app/src/composer/adapter.ts +++ b/packages/app/src/composer/adapter.ts @@ -1,4 +1,5 @@ import type { Data } from "@opencode-ai/client/solid" +import type { SessionMessageUser } from "@opencode-ai/client/promise" import type { Accessor } from "solid-js" import type { ModelSelection } from "@/providers/models/selection" import type { ServerSDK } from "@/runtime/server/client" @@ -41,6 +42,10 @@ export type ComposerSelection = { export type ComposerSession = { id: string directory: string + handoff?: { + set: (message: SessionMessageUser) => void + clear: (messageID: string) => void + } api: { command: (input: Parameters[0]) => Promise shell: (input: Parameters[0]) => Promise diff --git a/packages/app/src/composer/submit.test.ts b/packages/app/src/composer/submit.test.ts index 0e9e7a13bf3..b683684eb0c 100644 --- a/packages/app/src/composer/submit.test.ts +++ b/packages/app/src/composer/submit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import type { ModelSelection } from "@/providers/models/selection" +import type { SessionMessageUser } from "@opencode-ai/client/promise" import { Skill } from "@opencode-ai/schema/skill" import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter" import { createMemoryComposerState } from "./state" @@ -69,6 +70,7 @@ function submitInput( function session(input: { calls: string[] prompt: (value: Parameters[0]) => Promise + handoff?: ComposerSession["handoff"] statuses?: ("idle" | "running")[] current?: ComposerSession["current"] admitted?: (messageID: string) => boolean @@ -78,6 +80,7 @@ function session(input: { return { id: "session-1", directory: "C:/repo", + handoff: input.handoff, current: input.current ?? (() => undefined), admitted: input.admitted ?? (() => false), api: { @@ -180,6 +183,52 @@ describe("Composer submission", () => { expect(promoted.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }]) }) + test("hands off image-only first prompts before admission", async () => { + const draft = createMemoryComposerState().capture() + draft.set([ + { type: "text", content: "", start: 0, end: 0 }, + { + type: "image", + id: "attachment", + filename: "image.png", + mime: "image/png", + blob: { id: "attachment", url: "data:image/png;base64,YQ==" }, + }, + ]) + const handedOff = Promise.withResolvers() + const target = session({ + calls: [], + handoff: { set: handedOff.resolve, clear() {} }, + prompt: async () => undefined, + }) + const adapter: NewSessionComposerAdapter = { + kind: "new-session", + state: draft, + ready: () => true, + controls, + working: () => false, + submitted() {}, + async start() { + return { session: target, cleanupReady: Promise.resolve() } + }, + } + + await submitInput(adapter).submit(new Event("submit")) + + expect(await handedOff.promise).toMatchObject({ + type: "user", + text: "", + files: [ + { + data: "", + mime: "image/png", + source: { type: "uri", uri: "data:image/png;base64,YQ==" }, + name: "image.png", + }, + ], + }) + }) + test("does not restore a prompt already acknowledged by the durable inbox", async () => { const state = createMemoryComposerState({ prompt: "admitted prompt" }).capture() const checked = Promise.withResolvers() diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index 4e6edc4c0ff..6829c1d279c 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -1,4 +1,5 @@ import { SessionMessage } from "@opencode-ai/schema/session-message" +import type { SessionMessageUser } from "@opencode-ai/client/promise" import { Event } from "@opencode-ai/schema/event" import type { Accessor } from "solid-js" import type { PromptHistoryComment } from "./history/entry" @@ -78,6 +79,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) { const command = value.mode === "normal" ? findCommand(session, value.text) : undefined if (value.mode === "normal" && !command) { + if (value.images.length > 0) session.handoff?.set(handoffMessage(value)) const optimisticBusy = !input.adapter.working() if (optimisticBusy) session.data.session.setStatus(session.id, "running") const sending = sendPrompt(session, value).then( @@ -128,6 +130,29 @@ export function createComposerSubmit(input: ComposerSubmitInput) { } } +function handoffMessage(value: ComposerSubmission): SessionMessageUser { + return { + id: value.id, + type: "user", + text: value.text, + files: value.images.map((image) => ({ + data: "", + mime: image.mime, + source: { type: "uri", uri: image.blob.url }, + name: image.sourcePath ?? image.filename, + })), + metadata: { + displayText: value.text, + agent: value.selection.agent, + model: { + ...value.selection.model, + ...(value.selection.variant ? { variant: value.selection.variant } : {}), + }, + }, + time: { created: Date.now() }, + } +} + function readSubmission( input: ComposerSubmitInput, prompt: Prompt, @@ -334,6 +359,7 @@ function failSubmission( rollback?: () => void, ) { if (messageID && session.admitted(messageID)) return + if (messageID) session.handoff?.clear(messageID) rollback?.() restore() input.notify.failed(kind, error) diff --git a/packages/app/src/new-session/composer-adapter.ts b/packages/app/src/new-session/composer-adapter.ts index 7aa678e15de..7b4f953a0f2 100644 --- a/packages/app/src/new-session/composer-adapter.ts +++ b/packages/app/src/new-session/composer-adapter.ts @@ -1,5 +1,6 @@ import { base64Encode } from "@opencode-ai/util/encode" import { getDirectory } from "@opencode-ai/util/path" +import type { SessionMessageUser } from "@opencode-ai/client/promise" import { startTransition } from "solid-js" import type { NewSessionComposerAdapter } from "@/composer/adapter" import { useComposerState } from "@/composer/persistence" @@ -9,11 +10,13 @@ import { useLanguage } from "@/runtime/i18n/language" import { useLocal } from "@/providers/models/selection" import { usePermission } from "@/session/requests/permission" import { useData, useServer } from "@/runtime/server/current" -import { useServerSDK } from "@/runtime/server/client" +import { type ServerSDK, useServerSDK } from "@/runtime/server/client" import { useTabs } from "@/shell/tabs/tabs" import { useWorkspaceLocation } from "@/workspaces/location" import { useSessionKey } from "@/session/session-layout" import { showToast } from "@/shell/notifications/toast" +import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope" +import { clearSessionMessageHandoff, setSessionMessageHandoff } from "@/session/handoff" export function createNewSessionComposerAdapter(props: { draftID: string @@ -77,7 +80,10 @@ export function createNewSessionComposerAdapter(props: { if (!result.ok) throw result.error return run() } - + const sessionKey = SessionStateKey.from( + serverSDK.scope, + SessionRouteKey.fromRoute(base64Encode(sessionDirectory), created.id), + ) const cleanupReady = startTransition(() => { tabs.updateDraft(props.draftID, { worktree: undefined }) if (permission.isAutoAcceptingDirectory(projectDirectory)) { @@ -102,6 +108,7 @@ export function createNewSessionComposerAdapter(props: { session: { id: created.id, directory: sessionDirectory, + handoff: createMessageHandoff(sessionKey, created.id, serverSDK.event), api: { command: (input) => afterCreation(() => serverSDK.api.session.command(input)), shell: (input) => afterCreation(() => serverSDK.api.session.shell(input)), @@ -135,6 +142,27 @@ export function createNewSessionComposerAdapter(props: { } } +function createMessageHandoff(key: string, sessionID: string, event: ServerSDK["event"]) { + let unsubscribe: VoidFunction | undefined + return { + set(message: SessionMessageUser) { + unsubscribe?.() + setSessionMessageHandoff(key, message) + unsubscribe = event.on("session.inbox.enqueued", (item) => { + if (item.data.sessionID !== sessionID || item.data.inboxID !== message.id) return + unsubscribe?.() + unsubscribe = undefined + clearSessionMessageHandoff(key, message.id) + }) + }, + clear(messageID: string) { + unsubscribe?.() + unsubscribe = undefined + clearSessionMessageHandoff(key, messageID) + }, + } +} + async function resolveSessionDirectory(input: { projectDirectory: string worktree: string diff --git a/packages/app/src/session/handoff.test.ts b/packages/app/src/session/handoff.test.ts new file mode 100644 index 00000000000..205ad5d192a --- /dev/null +++ b/packages/app/src/session/handoff.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test" +import type { SessionMessageUser } from "@opencode-ai/client/promise" +import { clearSessionMessageHandoff, getSessionMessageHandoff, setSessionMessageHandoff } from "./handoff" + +test("stores and clears a message handoff", () => { + const message = { + id: "msg_handoff", + type: "user", + text: "", + files: [{ data: "", mime: "image/png", source: { type: "uri", uri: "blob:image" } }], + time: { created: 1 }, + } satisfies SessionMessageUser + + setSessionMessageHandoff("session-key", message) + expect(getSessionMessageHandoff("session-key")).toEqual(message) + clearSessionMessageHandoff("session-key", message.id) + expect(getSessionMessageHandoff("session-key")).toBeUndefined() +}) diff --git a/packages/app/src/session/handoff.ts b/packages/app/src/session/handoff.ts index 128bcade43c..6e00814c7ef 100644 --- a/packages/app/src/session/handoff.ts +++ b/packages/app/src/session/handoff.ts @@ -1,4 +1,6 @@ import type { SelectedLineRange } from "@/workspaces/files/model" +import type { SessionMessageUser } from "@opencode-ai/client/promise" +import { createStore } from "solid-js/store" type HandoffSession = { files: Record @@ -10,6 +12,8 @@ const store = { session: new Map(), terminal: new Map(), } +const [messages, setMessages] = createStore>({}) +const messageOrder = new Map() const touch = (map: Map, key: K, value: V) => { map.delete(key) @@ -28,6 +32,26 @@ export const setSessionHandoff = (key: string, patch: Partial) = export const getSessionHandoff = (key: string) => store.session.get(key) +export const setSessionMessageHandoff = (key: string, message: SessionMessageUser) => { + messageOrder.delete(key) + messageOrder.set(key, true) + setMessages(key, message) + while (messageOrder.size > MAX) { + const first = messageOrder.keys().next().value + if (first === undefined) return + messageOrder.delete(first) + setMessages(first, undefined) + } +} + +export const getSessionMessageHandoff = (key: string) => messages[key] + +export const clearSessionMessageHandoff = (key: string, messageID: string) => { + if (messages[key]?.id !== messageID) return + messageOrder.delete(key) + setMessages(key, undefined) +} + export const setTerminalHandoff = (key: string, value: string[]) => { touch(store.terminal, key, value) } diff --git a/packages/app/src/session/timeline/controller-projection.test.ts b/packages/app/src/session/timeline/controller-projection.test.ts index 1ee9b5d01e5..8783a78633b 100644 --- a/packages/app/src/session/timeline/controller-projection.test.ts +++ b/packages/app/src/session/timeline/controller-projection.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { SessionInboxInfo, SessionMessageInfo } from "@opencode-ai/client/promise" -import { visibleTimelineMessages } from "./controller-projection" +import { applyTimelineMessageHandoff, visibleTimelineMessages } from "./controller-projection" const messages = [ { id: "msg_1", type: "user", text: "first", time: { created: 1 } }, @@ -41,3 +41,37 @@ describe("visibleTimelineMessages", () => { expect(visibleTimelineMessages(messages, [], "msg_0")).toEqual([]) }) }) + +describe("applyTimelineMessageHandoff", () => { + const handoff = { + id: "msg_image", + type: "user", + text: "", + files: [ + { + data: "", + mime: "image/png", + source: { type: "uri", uri: "blob:image" }, + name: "image.png", + }, + ], + time: { created: 1 }, + } satisfies SessionMessageInfo + + test("shows a promoted image-only prompt before client admission", () => { + expect(applyTimelineMessageHandoff([], handoff)).toEqual([handoff]) + }) + + test("adds attachments to the client's optimistic row", () => { + const optimistic = { id: handoff.id, type: "user", text: "", time: { created: 2 } } satisfies SessionMessageInfo + expect(applyTimelineMessageHandoff([optimistic], handoff)).toEqual([{ ...optimistic, files: handoff.files }]) + }) + + test("keeps the durable attachment payload", () => { + const durable = { + ...handoff, + files: [{ data: "YQ==", mime: "image/png", source: { type: "inline" } }], + } satisfies SessionMessageInfo + expect(applyTimelineMessageHandoff([durable], handoff)).toEqual([durable]) + }) +}) diff --git a/packages/app/src/session/timeline/controller-projection.ts b/packages/app/src/session/timeline/controller-projection.ts index 555d1891554..5e4fca0503d 100644 --- a/packages/app/src/session/timeline/controller-projection.ts +++ b/packages/app/src/session/timeline/controller-projection.ts @@ -1,4 +1,13 @@ -import type { SessionInboxInfo, SessionMessageInfo } from "@opencode-ai/client/promise" +import type { SessionInboxInfo, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise" + +export function applyTimelineMessageHandoff(messages: SessionMessageInfo[], handoff?: SessionMessageUser) { + if (!handoff) return messages + const index = messages.findIndex((message) => message.id === handoff.id) + if (index < 0) return [...messages, handoff] + const message = messages[index] + if (message.type !== "user" || message.files?.length) return messages + return messages.map((item, current) => (current === index ? { ...message, files: handoff.files } : item)) +} export function visibleTimelineMessages( messages: SessionMessageInfo[], diff --git a/packages/app/src/session/timeline/controller.tsx b/packages/app/src/session/timeline/controller.tsx index f9ad86d442e..9b1edf0d799 100644 --- a/packages/app/src/session/timeline/controller.tsx +++ b/packages/app/src/session/timeline/controller.tsx @@ -16,9 +16,15 @@ import { sessionHref } from "@/shell/routes/session" import { sessionTitle } from "@/session/title" import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export" import { showToast } from "@/shell/notifications/toast" -import { timelineChildTitle, timelineRemovedSessionIDs, visibleTimelineMessages } from "./controller-projection" +import { + applyTimelineMessageHandoff, + timelineChildTitle, + timelineRemovedSessionIDs, + visibleTimelineMessages, +} from "./controller-projection" import { createTimelineProjection } from "./projection" import { useServer } from "@/runtime/server/current" +import { getSessionMessageHandoff } from "@/session/handoff" const emptyMessages: SessionMessageInfo[] = [] const taskDescription = (message: SessionMessageInfo, sessionID: string): string | undefined => { @@ -52,10 +58,16 @@ export function createTimelineController(input: { session: TimelineSessionSource const tabs = useTabs() const dialog = useDialog() const language = useLanguage() + const handedOffMessages = createMemo(() => + applyTimelineMessageHandoff( + input.session.history.messages(), + getSessionMessageHandoff(input.session.identity.sessionKey()), + ), + ) const projectedMessages = createMemo(() => { const id = input.session.identity.sessionID() return visibleTimelineMessages( - input.session.history.messages(), + handedOffMessages(), id ? data.session.pending.list(id) : [], input.session.data.info()?.revert?.messageID, )