fix(app): hand off optimistic attachments (#44411)

This commit is contained in:
Brendan Allan 2026-08-23 22:59:51 +08:00 committed by GitHub
parent fb9c9a2cbd
commit dd780ca882
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 211 additions and 6 deletions

View file

@ -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<ServerSDK["api"]["session"]["command"]>[0]) => Promise<unknown>
shell: (input: Parameters<ServerSDK["api"]["session"]["shell"]>[0]) => Promise<unknown>

View file

@ -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<ComposerSession["data"]["session"]["prompt"]>[0]) => Promise<void>
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<SessionMessageUser>()
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<void>()

View file

@ -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)

View file

@ -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

View file

@ -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()
})

View file

@ -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<string, SelectedLineRange | null>
@ -10,6 +12,8 @@ const store = {
session: new Map<string, HandoffSession>(),
terminal: new Map<string, string[]>(),
}
const [messages, setMessages] = createStore<Record<string, SessionMessageUser | undefined>>({})
const messageOrder = new Map<string, true>()
const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
map.delete(key)
@ -28,6 +32,26 @@ export const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) =
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)
}

View file

@ -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])
})
})

View file

@ -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[],

View file

@ -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,
)