mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-29 23:42:14 +00:00
fix(app): hand off optimistic attachments (#44411)
This commit is contained in:
parent
fb9c9a2cbd
commit
dd780ca882
9 changed files with 211 additions and 6 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
18
packages/app/src/session/handoff.test.ts
Normal file
18
packages/app/src/session/handoff.test.ts
Normal 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()
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue