mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-17 12:58:34 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f543a66be7 |
@@ -59,8 +59,10 @@ import {
|
|||||||
import { useData } from "../../context/data"
|
import { useData } from "../../context/data"
|
||||||
import { usePromptRef } from "../../context/prompt"
|
import { usePromptRef } from "../../context/prompt"
|
||||||
import { useLocation } from "../../context/location"
|
import { useLocation } from "../../context/location"
|
||||||
import type { PromptFileAttachment, PromptSkillAttachment, SkillInfo } from "@opencode-ai/client"
|
import type { LocationRef, PromptFileAttachment, PromptSkillAttachment, SkillInfo } from "@opencode-ai/client"
|
||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
|
import { SessionID } from "@opencode-ai/schema/session-id"
|
||||||
|
import { useSessionTabs } from "../../context/session-tabs"
|
||||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||||
import { abbreviateHome } from "../../runtime"
|
import { abbreviateHome } from "../../runtime"
|
||||||
import { Slot } from "../../plugin/render"
|
import { Slot } from "../../plugin/render"
|
||||||
@@ -258,6 +260,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const activePrompt = usePromptRef()
|
const activePrompt = usePromptRef()
|
||||||
|
const sessionTabs = useSessionTabs()
|
||||||
const directoryRecents = useDirectoryRecents()
|
const directoryRecents = useDirectoryRecents()
|
||||||
const keymapCommands = Keymap.useCommands()
|
const keymapCommands = Keymap.useCommands()
|
||||||
const currentLocation = useLocation()
|
const currentLocation = useLocation()
|
||||||
@@ -1242,6 +1245,8 @@ export function Prompt(props: PromptProps) {
|
|||||||
const variant = selection.variant
|
const variant = selection.variant
|
||||||
let sessionID = props.sessionID
|
let sessionID = props.sessionID
|
||||||
let finishMoveProgress = false
|
let finishMoveProgress = false
|
||||||
|
let createSession: (() => Promise<unknown>) | undefined
|
||||||
|
let createLocation: LocationRef | undefined
|
||||||
if (sessionID == null) {
|
if (sessionID == null) {
|
||||||
const directory = await move.getDirectory()
|
const directory = await move.getDirectory()
|
||||||
if (move.pending() && !directory) return false
|
if (move.pending() && !directory) return false
|
||||||
@@ -1249,31 +1254,46 @@ export function Prompt(props: PromptProps) {
|
|||||||
// The location context is where the next session is created: seeded by the home
|
// The location context is where the next session is created: seeded by the home
|
||||||
// route (launch cwd, inherited session location, or picked project) and updated
|
// route (launch cwd, inherited session location, or picked project) and updated
|
||||||
// by /cd before a session exists.
|
// by /cd before a session exists.
|
||||||
const location = currentLocation.ref ?? data.location.default()
|
const location = directory ? { directory } : (currentLocation.ref ?? data.location.default())
|
||||||
|
const model = { providerID: selection.providerID, id: selection.modelID, variant }
|
||||||
const created = await client.api.session
|
// Seed the session locally with a client-generated ID and create it in the
|
||||||
.create({
|
// background so navigation and the prompt echo render immediately. Optimistic
|
||||||
location: directory ? { directory } : location,
|
// creation covers the plain prompt path when the location's project is known.
|
||||||
|
// Worktree-backed creation keeps its progress flow, and command, skill, and
|
||||||
|
// shell submissions target the session right away, so those await creation.
|
||||||
|
const plainPrompt = store.mode !== "shell" && !(slashHead && isCommand) && !isSkill
|
||||||
|
const projectID = directory ? undefined : data.location.info(location)?.project.id
|
||||||
|
if (plainPrompt && projectID) {
|
||||||
|
const created = SessionID.create()
|
||||||
|
data.session.optimistic.create({
|
||||||
|
sessionID: created,
|
||||||
|
projectID,
|
||||||
|
location,
|
||||||
agent: agent.id,
|
agent: agent.id,
|
||||||
model: {
|
model,
|
||||||
providerID: selection.providerID,
|
|
||||||
id: selection.modelID,
|
|
||||||
variant,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
sessionID = created
|
||||||
|
createLocation = location
|
||||||
if (!created) {
|
createSession = () => client.api.session.create({ id: created, location, agent: agent.id, model })
|
||||||
if (finishMoveProgress) move.finishSubmit()
|
|
||||||
toast.show({
|
|
||||||
message: "Creating a session failed. Open console for more details.",
|
|
||||||
variant: "error",
|
|
||||||
})
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionID = created.id
|
if (sessionID == null) {
|
||||||
|
const created = await client.api.session
|
||||||
|
.create({ location, agent: agent.id, model })
|
||||||
|
.catch(() => undefined)
|
||||||
|
|
||||||
|
if (!created) {
|
||||||
|
if (finishMoveProgress) move.finishSubmit()
|
||||||
|
toast.show({
|
||||||
|
message: "Creating a session failed. Open console for more details.",
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID = created.id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture mode before it gets reset
|
// Capture mode before it gets reset
|
||||||
@@ -1335,8 +1355,14 @@ export function Prompt(props: PromptProps) {
|
|||||||
// Mark the editor context sent with the echo so a rapid follow-up submit
|
// Mark the editor context sent with the echo so a rapid follow-up submit
|
||||||
// does not re-attach the same selection while admission is in flight.
|
// does not re-attach the same selection while admission is in flight.
|
||||||
if (editorContextText) editor.markSelectionSent()
|
if (editorContextText) editor.markSelectionSent()
|
||||||
|
let createFailed = false
|
||||||
void enqueueSubmit(submitSessionID, async () => {
|
void enqueueSubmit(submitSessionID, async () => {
|
||||||
const error = await (async () => {
|
const error = await (async () => {
|
||||||
|
if (createSession)
|
||||||
|
await createSession().catch((error) => {
|
||||||
|
createFailed = true
|
||||||
|
throw error
|
||||||
|
})
|
||||||
let session = data.session.get(submitSessionID)
|
let session = data.session.get(submitSessionID)
|
||||||
if (!session) {
|
if (!session) {
|
||||||
await data.session.sync(submitSessionID)
|
await data.session.sync(submitSessionID)
|
||||||
@@ -1375,6 +1401,17 @@ export function Prompt(props: PromptProps) {
|
|||||||
)
|
)
|
||||||
if (error === undefined) return
|
if (error === undefined) return
|
||||||
data.session.optimistic.rollback(submitSessionID, messageID)
|
data.session.optimistic.rollback(submitSessionID, messageID)
|
||||||
|
if (createFailed) {
|
||||||
|
// The session never existed server-side: leave the optimistic tab, return
|
||||||
|
// to the home screen with the prompt restored, and drop the seed.
|
||||||
|
if (route.data.type === "session" && route.data.sessionID === submitSessionID)
|
||||||
|
route.navigate({ type: "home", prompt: snapshot, location: createLocation })
|
||||||
|
else saveDraft(undefined, { prompt: snapshot, cursor: snapshot.text.length })
|
||||||
|
sessionTabs.close(submitSessionID)
|
||||||
|
data.session.optimistic.rollbackCreate(submitSessionID)
|
||||||
|
toast.show({ title: "Failed to create session", message: errorMessage(error), variant: "error" })
|
||||||
|
return
|
||||||
|
}
|
||||||
restorePrompt(submitSessionID, snapshot)
|
restorePrompt(submitSessionID, snapshot)
|
||||||
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
|
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
messageIndex.delete(sessionID)
|
messageIndex.delete(sessionID)
|
||||||
optimisticPrompts.delete(sessionID)
|
optimisticPrompts.delete(sessionID)
|
||||||
sync.invalidate(`session:${sessionID}`)
|
sync.invalidate(`session:${sessionID}`)
|
||||||
|
sync.invalidate(`session.family:${sessionID}`)
|
||||||
sync.invalidate(`session.pending:${sessionID}`)
|
sync.invalidate(`session.pending:${sessionID}`)
|
||||||
sync.invalidate(`session.message:${sessionID}`)
|
sync.invalidate(`session.message:${sessionID}`)
|
||||||
sync.invalidate(`session.permission:${sessionID}`)
|
sync.invalidate(`session.permission:${sessionID}`)
|
||||||
@@ -1066,6 +1067,41 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
optimistic: {
|
optimistic: {
|
||||||
|
// Seed a session locally before server creation so navigation and the first
|
||||||
|
// prompt echo render immediately. Reads are marked complete so the session
|
||||||
|
// route does not fetch a session the server does not know yet; the
|
||||||
|
// session.created echo invalidates the info read and loads server truth.
|
||||||
|
create(input: {
|
||||||
|
sessionID: string
|
||||||
|
projectID: string
|
||||||
|
location: LocationRef
|
||||||
|
agent?: string
|
||||||
|
model?: SessionInfo["model"]
|
||||||
|
}) {
|
||||||
|
const now = Date.now()
|
||||||
|
const info: SessionInfo = {
|
||||||
|
id: input.sessionID,
|
||||||
|
projectID: input.projectID,
|
||||||
|
location: input.location,
|
||||||
|
agent: input.agent,
|
||||||
|
model: input.model,
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: now, updated: now },
|
||||||
|
}
|
||||||
|
batch(() => {
|
||||||
|
setStore("session", "info", input.sessionID, info)
|
||||||
|
registerSession(input.sessionID)
|
||||||
|
})
|
||||||
|
sync.complete(`session:${input.sessionID}`)
|
||||||
|
sync.complete(`session.family:${input.sessionID}`)
|
||||||
|
sync.complete(`session.pending:${input.sessionID}`)
|
||||||
|
sync.complete(`session.message:${input.sessionID}`)
|
||||||
|
},
|
||||||
|
// Remove a seeded session after creation fails. Callers navigate away first.
|
||||||
|
rollbackCreate(sessionID: string) {
|
||||||
|
removeSession(sessionID)
|
||||||
|
},
|
||||||
// Locally echo a user prompt before server admission. The session.inbox.enqueued
|
// Locally echo a user prompt before server admission. The session.inbox.enqueued
|
||||||
// echo carrying the same message ID replaces the copy with server truth; rollback
|
// echo carrying the same message ID replaces the copy with server truth; rollback
|
||||||
// removes the echo when submission fails.
|
// removes the echo when submission fails.
|
||||||
|
|||||||
@@ -231,6 +231,108 @@ test("cancellation clears the optimistic echo for good", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("optimistic session creation seeds info and suppresses initial reads", async () => {
|
||||||
|
const events = createEventStream()
|
||||||
|
const sessionID = "ses_optimistic"
|
||||||
|
const fetched: string[] = []
|
||||||
|
const calls = createFetch((url) => {
|
||||||
|
if (!url.pathname.startsWith(`/api/session/${sessionID}`)) return
|
||||||
|
fetched.push(url.pathname)
|
||||||
|
if (url.pathname === `/api/session/${sessionID}`)
|
||||||
|
return json({
|
||||||
|
data: {
|
||||||
|
id: sessionID,
|
||||||
|
projectID: "proj_test",
|
||||||
|
location: { directory },
|
||||||
|
agent: "build",
|
||||||
|
title: "Server title",
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, events)
|
||||||
|
const { app, data } = await renderData(calls.fetch)
|
||||||
|
|
||||||
|
try {
|
||||||
|
data.session.optimistic.create({
|
||||||
|
sessionID,
|
||||||
|
projectID: "proj_test",
|
||||||
|
location: { directory },
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "provider", id: "model" },
|
||||||
|
})
|
||||||
|
const seeded = data.session.get(sessionID)
|
||||||
|
expect(seeded?.projectID).toBe("proj_test")
|
||||||
|
expect(seeded?.agent).toBe("build")
|
||||||
|
|
||||||
|
// Seeded reads are marked complete: the server does not know the session yet.
|
||||||
|
await data.session.sync(sessionID, { children: true })
|
||||||
|
await data.session.message.sync(sessionID)
|
||||||
|
await data.session.pending.sync(sessionID)
|
||||||
|
expect(fetched).toEqual([])
|
||||||
|
|
||||||
|
// The session.created echo invalidates the info read and loads server truth.
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_created",
|
||||||
|
created: 2,
|
||||||
|
type: "session.created",
|
||||||
|
durable: durable(sessionID),
|
||||||
|
data: {
|
||||||
|
sessionID,
|
||||||
|
projectID: "proj_test",
|
||||||
|
location: { directory },
|
||||||
|
slug: "server-slug",
|
||||||
|
agent: "build",
|
||||||
|
version: "test",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await wait(() => data.session.get(sessionID)?.title === "Server title")
|
||||||
|
expect(fetched).toEqual([`/api/session/${sessionID}`])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rollbackCreate removes the seeded session and re-enables reads", async () => {
|
||||||
|
const events = createEventStream()
|
||||||
|
const sessionID = "ses_rollback"
|
||||||
|
const fetched: string[] = []
|
||||||
|
const calls = createFetch((url) => {
|
||||||
|
if (url.pathname !== `/api/session/${sessionID}/message`) return
|
||||||
|
fetched.push(url.pathname)
|
||||||
|
return json({ data: [], cursor: {} })
|
||||||
|
}, events)
|
||||||
|
const { app, data } = await renderData(calls.fetch)
|
||||||
|
|
||||||
|
try {
|
||||||
|
data.session.optimistic.create({
|
||||||
|
sessionID,
|
||||||
|
projectID: "proj_test",
|
||||||
|
location: { directory },
|
||||||
|
})
|
||||||
|
data.session.optimistic.prompt({
|
||||||
|
sessionID,
|
||||||
|
messageID: "msg_first",
|
||||||
|
delivery: "steer",
|
||||||
|
text: "First prompt",
|
||||||
|
})
|
||||||
|
expect(data.session.message.list(sessionID)).toHaveLength(1)
|
||||||
|
|
||||||
|
data.session.optimistic.rollback(sessionID, "msg_first")
|
||||||
|
data.session.optimistic.rollbackCreate(sessionID)
|
||||||
|
expect(data.session.get(sessionID)).toBeUndefined()
|
||||||
|
expect(data.session.message.list(sessionID)).toHaveLength(0)
|
||||||
|
expect(data.session.pending.list(sessionID)).toHaveLength(0)
|
||||||
|
|
||||||
|
// The seed's completed read markers are gone with it.
|
||||||
|
await data.session.message.sync(sessionID)
|
||||||
|
expect(fetched).toEqual([`/api/session/${sessionID}/message`])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("revert commit preserves unconfirmed optimistic prompts", async () => {
|
test("revert commit preserves unconfirmed optimistic prompts", async () => {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const sessionID = "session-optimistic-revert"
|
const sessionID = "session-optimistic-revert"
|
||||||
|
|||||||
Reference in New Issue
Block a user