feat(tui): optimistic session creation on first prompt (#43687)
This commit is contained in:
@@ -13,6 +13,7 @@ import type {
|
||||
McpResource,
|
||||
McpServer,
|
||||
ModelInfo,
|
||||
ModelRef,
|
||||
PermissionSavedInfo,
|
||||
PermissionRequest,
|
||||
PermissionReplyInput,
|
||||
@@ -34,6 +35,7 @@ import type {
|
||||
WebSearchProvider,
|
||||
} from "../promise"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { SessionID } from "@opencode-ai/schema/session-id"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { isPermissionNotFoundError, type SessionPromptInput } from "../promise"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
@@ -226,6 +228,33 @@ export function createData(config: CreateDataInput) {
|
||||
// rollback — not on POST success, which typically precedes the echo.
|
||||
const outbox = new Set<string>()
|
||||
|
||||
// Session IDs of optimistic create admissions still awaiting acknowledgement
|
||||
// (the session.created echo or the create response itself). A failed create
|
||||
// only rolls back a session the server never acknowledged. Unlike
|
||||
// `creating`, this clears on the echo rather than request settlement.
|
||||
const sessionOutbox = new Set<string>()
|
||||
|
||||
// In-flight optimistic creates by session ID. prompt() gates its POST on
|
||||
// this so a prompt sent to a still-creating session waits for the session
|
||||
// to exist server-side instead of failing with "not found".
|
||||
const creating = new Map<string, Promise<unknown>>()
|
||||
|
||||
// Per-session send chain: prompts must be admitted in submission order,
|
||||
// and HTTP gives no ordering across concurrent POSTs. Each prompt waits
|
||||
// for the previous prompt's POST (settled, so one failure does not block
|
||||
// the next) before sending its own.
|
||||
const sending = new Map<string, Promise<unknown>>()
|
||||
|
||||
// Register `promise` under `key` until it settles. A later registration
|
||||
// replaces an earlier one; settlement only clears its own entry.
|
||||
function track(map: Map<string, Promise<unknown>>, key: string, promise: Promise<unknown>) {
|
||||
map.set(key, promise)
|
||||
const settle = () => {
|
||||
if (map.get(key) === promise) map.delete(key)
|
||||
}
|
||||
void promise.then(settle, settle)
|
||||
}
|
||||
|
||||
// Upsert an admitted inbox item into pending, input, and (for user and
|
||||
// synthetic items) the visible transcript. Used by the inbox.enqueued
|
||||
// handler and by optimistic prompt admission; the upsert is what reconciles
|
||||
@@ -385,6 +414,7 @@ export function createData(config: CreateDataInput) {
|
||||
store.session.pending[sessionID]?.forEach((item) => outbox.delete(item.id))
|
||||
messageIndex.delete(sessionID)
|
||||
sync.invalidate(`session:${sessionID}`)
|
||||
sync.invalidate(`session.family:${sessionID}`)
|
||||
sync.invalidate(`session.pending:${sessionID}`)
|
||||
sync.invalidate(`session.message:${sessionID}`)
|
||||
sync.invalidate(`session.permission:${sessionID}`)
|
||||
@@ -434,6 +464,7 @@ export function createData(config: CreateDataInput) {
|
||||
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
|
||||
return
|
||||
case "session.created":
|
||||
sessionOutbox.delete(event.data.sessionID)
|
||||
result.session.invalidate(event.data.sessionID)
|
||||
void result.session.sync(event.data.sessionID)
|
||||
// Band-aid: a newly created session starts empty, so live events can be its source of truth.
|
||||
@@ -1110,46 +1141,117 @@ export function createData(config: CreateDataInput) {
|
||||
sync.invalidate(`session.pending:${sessionID}`)
|
||||
},
|
||||
},
|
||||
// Optimistic session creation: admit a local record under a
|
||||
// client-minted ID so a session view can mount immediately, then create
|
||||
// the session on the server. The session.created echo re-syncs the
|
||||
// record by ID, so the durable payload replaces the client's guess.
|
||||
// Returns the ID synchronously along with the in-flight request:
|
||||
// callers gate session-dependent sends on the request (prompt() gates
|
||||
// itself on any in-flight create of its session automatically).
|
||||
create(input: {
|
||||
id?: string
|
||||
title?: string
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
location?: LocationRef
|
||||
projectID?: string
|
||||
}) {
|
||||
const { projectID, ...payload } = input
|
||||
const id = payload.id ?? SessionID.create()
|
||||
const location = payload.location ?? defaultLocation()
|
||||
const fresh = !store.session.info[id]
|
||||
if (fresh) {
|
||||
const now = Date.now()
|
||||
sessionOutbox.add(id)
|
||||
result.session.remember({
|
||||
id,
|
||||
projectID: projectID ?? store.location[locationKey(location)]?.info?.project.id ?? "",
|
||||
agent: payload.agent,
|
||||
model: payload.model,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: now, updated: now },
|
||||
title: payload.title,
|
||||
location,
|
||||
})
|
||||
// A mounted optimistic session must not fetch its empty collections
|
||||
// before creation settles. The session.created echo re-syncs info.
|
||||
sync.complete(`session.family:${id}`)
|
||||
sync.complete(`session.pending:${id}`)
|
||||
sync.complete(`session.message:${id}`)
|
||||
}
|
||||
// Wrapped so even a synchronous client failure reaches the rollback.
|
||||
const request = Promise.resolve()
|
||||
.then(() => api().session.create({ ...payload, id, location }))
|
||||
.then((info) => {
|
||||
sessionOutbox.delete(id)
|
||||
result.session.remember(info)
|
||||
return info
|
||||
})
|
||||
.catch((error) => {
|
||||
// Roll back only a record this call admitted and neither the echo
|
||||
// nor the response has acknowledged: anything else is server state.
|
||||
if (fresh && sessionOutbox.delete(id)) removeSession(id)
|
||||
throw error
|
||||
})
|
||||
if (fresh) track(creating, id, request)
|
||||
return { id, request }
|
||||
},
|
||||
// Optimistic prompt admission: render the prompt immediately under a
|
||||
// client-minted ID, send it, and let the durable inbox.enqueued echo
|
||||
// upsert that same ID with the server's payload. Server admission is
|
||||
// idempotent per ID, so retrying with the identical payload cannot
|
||||
// double-admit.
|
||||
prompt(input: SessionPromptInput) {
|
||||
const id = input.id ?? SessionMessage.ID.create()
|
||||
prompt(input: SessionPromptInput & { gate?: Promise<unknown> }) {
|
||||
const { gate, ...request } = input
|
||||
const id = request.id ?? SessionMessage.ID.create()
|
||||
// A retry may reuse an ID that is already rendered — and possibly
|
||||
// already durable. Admit optimistically only for new IDs so a failed
|
||||
// retry cannot roll back acknowledged state.
|
||||
const fresh =
|
||||
!messageIndex.get(input.sessionID)?.has(id) &&
|
||||
!store.session.pending[input.sessionID]?.some((item) => item.id === id)
|
||||
!messageIndex.get(request.sessionID)?.has(id) &&
|
||||
!store.session.pending[request.sessionID]?.some((item) => item.id === id)
|
||||
if (fresh) {
|
||||
outbox.add(id)
|
||||
admitLocal({
|
||||
id,
|
||||
sessionID: input.sessionID,
|
||||
sessionID: request.sessionID,
|
||||
timeCreated: Date.now(),
|
||||
type: "user",
|
||||
delivery: input.delivery ?? "steer",
|
||||
delivery: request.delivery ?? "steer",
|
||||
// Files and skills stay off the optimistic row: their durable
|
||||
// forms are server-loaded (content, mime, resolution), so they
|
||||
// fill in when the echo upserts the row.
|
||||
payload: {
|
||||
text: input.text,
|
||||
agents: input.agents?.map((agent) => ({ ...agent })),
|
||||
metadata: input.metadata,
|
||||
text: request.text,
|
||||
agents: request.agents?.map((agent) => ({ ...agent })),
|
||||
metadata: request.metadata,
|
||||
},
|
||||
})
|
||||
}
|
||||
// Wrapped so even a synchronous client failure reaches the rollback.
|
||||
return Promise.resolve()
|
||||
.then(() => api().session.prompt({ ...input, id }))
|
||||
.catch((error) => {
|
||||
// Roll back only rows this call admitted and the echo has not
|
||||
// acknowledged: anything else is server state.
|
||||
if (fresh && outbox.delete(id)) retractLocal(input.sessionID, id)
|
||||
throw error
|
||||
})
|
||||
// The POST additionally waits for the caller's gate, for any
|
||||
// in-flight optimistic create of this session, and for the previous
|
||||
// prompt's POST: the row renders now, the send happens once the
|
||||
// session exists server-side and earlier prompts are admitted.
|
||||
const previous = sending.get(request.sessionID)
|
||||
const send = Promise.resolve()
|
||||
.then(() => Promise.all([gate, creating.get(request.sessionID), previous]))
|
||||
.then(() => api().session.prompt({ ...request, id }))
|
||||
track(
|
||||
sending,
|
||||
request.sessionID,
|
||||
send.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
),
|
||||
)
|
||||
return send.catch((error) => {
|
||||
// Roll back only rows this call admitted and the echo has not
|
||||
// acknowledged: anything else is server state.
|
||||
if (fresh && outbox.delete(id)) retractLocal(request.sessionID, id)
|
||||
throw error
|
||||
})
|
||||
},
|
||||
sync(sessionID: string, options?: { children?: boolean }) {
|
||||
return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => {
|
||||
|
||||
@@ -19,6 +19,8 @@ import { useClipboard } from "../../context/clipboard"
|
||||
import { Spinner } from "../spinner"
|
||||
import { useClient } from "../../context/client"
|
||||
import { useRoute } from "../../context/route"
|
||||
import { usePromptRef } from "../../context/prompt"
|
||||
import { useSessionTabs } from "../../context/session-tabs"
|
||||
import { useEvent } from "../../context/event"
|
||||
import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor"
|
||||
import { normalizePromptContent, openEditor } from "../../editor"
|
||||
@@ -201,6 +203,8 @@ export function Prompt(props: PromptProps) {
|
||||
const client = useClient()
|
||||
const editor = useEditorContext()
|
||||
const route = useRoute()
|
||||
const promptRef = usePromptRef()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const data = useData()
|
||||
const directoryRecents = useDirectoryRecents()
|
||||
const keymapCommands = Keymap.useCommands()
|
||||
@@ -688,16 +692,20 @@ export function Prompt(props: PromptProps) {
|
||||
input.gotoBufferEnd()
|
||||
},
|
||||
reset() {
|
||||
input.clear()
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", emptyPrompt())
|
||||
setStore("extmarkToPart", new Map())
|
||||
resetComposer()
|
||||
},
|
||||
submit() {
|
||||
void submit()
|
||||
},
|
||||
}
|
||||
|
||||
function resetComposer() {
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", emptyPrompt())
|
||||
setStore("extmarkToPart", new Map())
|
||||
input.clear()
|
||||
}
|
||||
|
||||
// Captured once: the session route is keyed by sessionID, so this Prompt
|
||||
// instance belongs to exactly one tab. Reading props.sessionID lazily would
|
||||
// observe the *next* route during onCleanup and stash under the wrong tab.
|
||||
@@ -873,10 +881,7 @@ export function Prompt(props: PromptProps) {
|
||||
run: () => {
|
||||
if (!store.prompt.text) return
|
||||
stash.push({ prompt: store.prompt })
|
||||
input.extmarks.clear()
|
||||
input.clear()
|
||||
setStore("prompt", emptyPrompt())
|
||||
setStore("extmarkToPart", new Map())
|
||||
resetComposer()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -1168,102 +1173,139 @@ export function Prompt(props: PromptProps) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Snapshot the composer and clear it synchronously, before the first await.
|
||||
// Everything below reads the snapshot: text typed while a request is in
|
||||
// flight lands in the already-empty composer and survives, and prompt
|
||||
// history records exactly what was submitted instead of the live store
|
||||
// (which may have absorbed mid-flight typing). Failure paths restore the
|
||||
// snapshot unless the user has started typing something new.
|
||||
const currentMode = store.mode
|
||||
const entry = { ...store.prompt, mode: currentMode }
|
||||
history.append(entry)
|
||||
resetComposer()
|
||||
props.onSubmit?.()
|
||||
const restoreEntry = () => {
|
||||
if (disposed || input.isDestroyed || input.plainText !== "") return
|
||||
input.setText(entry.text)
|
||||
setStore("prompt", entry)
|
||||
setStore("mode", entry.mode ?? "normal")
|
||||
restoreExtmarksFromPrompt(entry)
|
||||
input.cursorOffset = entry.text.length
|
||||
}
|
||||
|
||||
const variant = selection.variant
|
||||
let sessionID = props.sessionID
|
||||
let session = sessionID ? data.session.get(sessionID) : undefined
|
||||
let finishMoveProgress = false
|
||||
// New-session sends wait for creation and environment setup.
|
||||
let newSession: { gate: Promise<unknown>; recover: (error: unknown) => void } | undefined
|
||||
if (sessionID == null) {
|
||||
const directory = await move.getDirectory()
|
||||
if (move.pending() && !directory) return false
|
||||
if (move.pending() && !directory) {
|
||||
restoreEntry()
|
||||
return false
|
||||
}
|
||||
finishMoveProgress = Boolean(move.progress())
|
||||
// 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
|
||||
// by /cd before a session exists.
|
||||
const location = currentLocation.ref ?? data.location.default()
|
||||
|
||||
const created = await client.api.session
|
||||
.create({
|
||||
location: directory ? { directory } : location,
|
||||
agent: agent.id,
|
||||
model: {
|
||||
providerID: selection.providerID,
|
||||
id: selection.modelID,
|
||||
variant,
|
||||
},
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
if (!created) {
|
||||
if (finishMoveProgress) move.finishSubmit()
|
||||
toast.show({
|
||||
message: "Creating a session failed. Open console for more details.",
|
||||
variant: "error",
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Optimistic create: the data layer mints the ID client-side and admits
|
||||
// a local session record synchronously, so the navigation below happens
|
||||
// immediately — enter feels sent even while the create round-trip is in
|
||||
// flight. Sends against the new session gate on the request.
|
||||
const created = data.session.create({
|
||||
location: directory ? { directory } : location,
|
||||
agent: agent.id,
|
||||
model: {
|
||||
providerID: selection.providerID,
|
||||
id: selection.modelID,
|
||||
variant,
|
||||
},
|
||||
})
|
||||
sessionID = created.id
|
||||
session = created
|
||||
if (created.location.workspaceID === undefined && terminalEnvironment.variables !== undefined) {
|
||||
const error = await client.api.session
|
||||
.environment({ sessionID, variables: terminalEnvironment.variables })
|
||||
.then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
if (error) {
|
||||
if (finishMoveProgress) move.finishSubmit()
|
||||
toast.show({ title: "Failed to set session environment", message: errorMessage(error), variant: "error" })
|
||||
return true
|
||||
}
|
||||
session = data.session.get(created.id)
|
||||
newSession = {
|
||||
gate: created.request.then(async (info) => {
|
||||
if (info.location.workspaceID === undefined && terminalEnvironment.variables !== undefined) {
|
||||
await client.api.session.environment({ sessionID: created.id, variables: terminalEnvironment.variables })
|
||||
}
|
||||
}),
|
||||
recover: (error) => {
|
||||
toast.show({
|
||||
title: data.session.get(created.id) ? "Failed to set up session" : "Creating a session failed",
|
||||
message: errorMessage(error),
|
||||
variant: "error",
|
||||
})
|
||||
const active =
|
||||
route.data.type === "session" && route.data.sessionID === created.id ? promptRef.current : undefined
|
||||
const current = active?.current
|
||||
const draft = current?.text
|
||||
? { prompt: { ...unwrap(current) }, cursor: current.text.length }
|
||||
: (takeDraft(created.id) ?? { prompt: entry, cursor: entry.text.length })
|
||||
saveDraft(undefined, draft)
|
||||
active?.reset()
|
||||
if (sessionTabs.enabled()) {
|
||||
sessionTabs.close(created.id)
|
||||
} else if (route.data.type === "session" && route.data.sessionID === created.id) {
|
||||
route.navigate({ type: "home" })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Capture mode before it gets reset
|
||||
const currentMode = store.mode
|
||||
if (store.mode === "shell") {
|
||||
const target = sessionID
|
||||
const dispatch = (send: () => Promise<unknown>) => {
|
||||
const setup = newSession
|
||||
if (setup) void setup.gate.then(send).catch(setup.recover)
|
||||
else void send()
|
||||
}
|
||||
if (currentMode === "shell") {
|
||||
move.startSubmit()
|
||||
void client.api.session.shell({
|
||||
sessionID,
|
||||
command: inputText,
|
||||
})
|
||||
dispatch(() => client.api.session.shell({ sessionID: target, command: inputText }))
|
||||
setStore("mode", "normal")
|
||||
} else if (slashHead && isCommand) {
|
||||
move.startSubmit()
|
||||
const model = { providerID: selection.providerID, id: selection.modelID, variant }
|
||||
const cancelCommit = local.model.trackSessionCommit(sessionID, model)
|
||||
const cancelCommit = local.model.trackSessionCommit(target, model)
|
||||
|
||||
void client.api.session
|
||||
.command({
|
||||
sessionID,
|
||||
const send = () =>
|
||||
client.api.session.command({
|
||||
sessionID: target,
|
||||
command: slashHead.name,
|
||||
arguments: slashHead.arguments,
|
||||
agent: agent.id,
|
||||
model,
|
||||
files: store.prompt.files,
|
||||
agents: store.prompt.agents,
|
||||
skills: store.prompt.skills?.length ? store.prompt.skills : undefined,
|
||||
files: entry.files,
|
||||
agents: entry.agents,
|
||||
skills: entry.skills?.length ? entry.skills : undefined,
|
||||
delivery,
|
||||
})
|
||||
.catch((error) => {
|
||||
cancelCommit()
|
||||
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
|
||||
})
|
||||
const setup = newSession
|
||||
void (setup ? setup.gate.then(send) : send()).catch((error) => {
|
||||
cancelCommit()
|
||||
if (setup) return setup.recover(error)
|
||||
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
|
||||
restoreEntry()
|
||||
})
|
||||
} else if (isSkill) {
|
||||
move.startSubmit()
|
||||
void client.api.session.skill({
|
||||
sessionID,
|
||||
skill: slashHead.name,
|
||||
})
|
||||
dispatch(() => client.api.session.skill({ sessionID: target, skill: slashHead.name }))
|
||||
} else {
|
||||
move.startSubmit()
|
||||
if (!session) {
|
||||
await data.session.sync(sessionID)
|
||||
session = data.session.get(sessionID)
|
||||
}
|
||||
if (session?.agent !== agent.id) {
|
||||
await client.api.session.switchAgent({ sessionID, agent: agent.id })
|
||||
try {
|
||||
if (!session) {
|
||||
await data.session.sync(target)
|
||||
session = data.session.get(target)
|
||||
}
|
||||
if (session?.agent !== agent.id) {
|
||||
await client.api.session.switchAgent({ sessionID: target, agent: agent.id })
|
||||
}
|
||||
} catch (error) {
|
||||
toast.show({ title: "Failed to prepare session", message: errorMessage(error), variant: "error" })
|
||||
restoreEntry()
|
||||
return true
|
||||
}
|
||||
if (
|
||||
session?.model?.providerID !== selection.providerID ||
|
||||
@@ -1271,83 +1313,94 @@ export function Prompt(props: PromptProps) {
|
||||
(session.model.variant ?? "default") !== (variant ?? "default")
|
||||
) {
|
||||
const model = { providerID: selection.providerID, id: selection.modelID, variant }
|
||||
const cancelCommit = local.model.trackSessionCommit(sessionID, model)
|
||||
await client.api.session.switchModel({ sessionID, model }).catch((error) => {
|
||||
const cancelCommit = local.model.trackSessionCommit(target, model)
|
||||
const switchError = await client.api.session.switchModel({ sessionID: target, model }).then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
if (switchError) {
|
||||
cancelCommit()
|
||||
throw error
|
||||
})
|
||||
toast.show({ title: "Failed to switch model", message: errorMessage(switchError), variant: "error" })
|
||||
restoreEntry()
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (session?.revert) {
|
||||
const error = await client.api.session.revert.commit({ sessionID }).then(
|
||||
const error = await client.api.session.revert.commit({ sessionID: target }).then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
if (error) {
|
||||
toast.show({ title: "Failed to commit revert", message: errorMessage(error), variant: "error" })
|
||||
restoreEntry()
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (pendingEditorSelection) {
|
||||
// Keep editor context hidden while admitting it before the corresponding user prompt.
|
||||
const error = await client.api.session
|
||||
.synthetic({
|
||||
sessionID,
|
||||
const send = () =>
|
||||
client.api.session.synthetic({
|
||||
sessionID: target,
|
||||
text: formatEditorContext(pendingEditorSelection),
|
||||
resume: false,
|
||||
})
|
||||
.then(
|
||||
if (newSession) {
|
||||
// Fold into the setup gate so the context still admits before the
|
||||
// user prompt once the session exists.
|
||||
newSession.gate = newSession.gate.then(send)
|
||||
} else {
|
||||
const error = await send().then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
if (error) {
|
||||
toast.show({ title: "Failed to send editor context", message: errorMessage(error), variant: "error" })
|
||||
return false
|
||||
if (error) {
|
||||
toast.show({ title: "Failed to send editor context", message: errorMessage(error), variant: "error" })
|
||||
restoreEntry()
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
// The data layer admits optimistically: the prompt renders immediately
|
||||
// and rolls back if the server rejects it, so submission does not wait
|
||||
// on the network. On rejection the row is already rolled back; restore
|
||||
// the composer unless the user has started typing something new.
|
||||
const entry = { ...store.prompt, mode: currentMode }
|
||||
data.session
|
||||
.prompt({
|
||||
sessionID,
|
||||
sessionID: target,
|
||||
text: inputText,
|
||||
files: store.prompt.files,
|
||||
agents: store.prompt.agents,
|
||||
skills: store.prompt.skills?.length ? store.prompt.skills : undefined,
|
||||
files: entry.files,
|
||||
agents: entry.agents,
|
||||
skills: entry.skills?.length ? entry.skills : undefined,
|
||||
delivery,
|
||||
gate: newSession?.gate,
|
||||
})
|
||||
.catch((error) => {
|
||||
if (newSession) return newSession.recover(error)
|
||||
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
|
||||
if (disposed || input.isDestroyed || input.plainText !== "") return
|
||||
input.setText(entry.text)
|
||||
setStore("prompt", entry)
|
||||
setStore("mode", entry.mode ?? "normal")
|
||||
restoreExtmarksFromPrompt(entry)
|
||||
input.cursorOffset = entry.text.length
|
||||
restoreEntry()
|
||||
})
|
||||
if (pendingEditorSelection) editor.markSelectionSent()
|
||||
}
|
||||
history.append({
|
||||
...store.prompt,
|
||||
mode: currentMode,
|
||||
})
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", emptyPrompt())
|
||||
setStore("extmarkToPart", new Map())
|
||||
props.onSubmit?.()
|
||||
|
||||
// Optimistic admission puts the message in the store synchronously, so
|
||||
// the session view renders it on arrival.
|
||||
if (!props.sessionID) {
|
||||
if (pendingEditorSelection) editor.preserveSelectionFromNewSession()
|
||||
// Text typed while session creation was in flight lives in this (home)
|
||||
// prompt, which unmounts on navigation and would stash it under the
|
||||
// home key. Re-stash it under the new session so that composer restores
|
||||
// it, and clear it here so onCleanup does not also stash it for home.
|
||||
if (!disposed && !input.isDestroyed && store.prompt.text) {
|
||||
// Copy before clearing: unwrap returns the live store target, and the
|
||||
// resetComposer store write merges into that same object.
|
||||
saveDraft(sessionID, { prompt: { ...unwrap(store.prompt) }, cursor: input.cursorOffset })
|
||||
resetComposer()
|
||||
}
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID,
|
||||
})
|
||||
}
|
||||
input.clear()
|
||||
if (finishMoveProgress) move.finishSubmit()
|
||||
return true
|
||||
}
|
||||
@@ -1509,10 +1562,7 @@ export function Prompt(props: PromptProps) {
|
||||
mode: store.mode,
|
||||
})
|
||||
}
|
||||
input.clear()
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", emptyPrompt())
|
||||
setStore("extmarkToPart", new Map())
|
||||
resetComposer()
|
||||
}
|
||||
|
||||
const highlight = createMemo(() => {
|
||||
|
||||
@@ -76,6 +76,13 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
let history: SessionTabHistory = { entries: [], index: -1 }
|
||||
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
|
||||
let closedTabs: ClosedSessionTab[] = []
|
||||
// Storage mutations apply against the on-disk draft under a file lock, so
|
||||
// a registration queued by the route effect can land AFTER a removal that
|
||||
// ran while the write was still in flight — resurrecting a tab that was
|
||||
// just closed. Removing a tab marks it cancelled so any late-applying
|
||||
// registration becomes a no-op; navigating to the session again clears
|
||||
// the mark.
|
||||
const cancelledTabs = new Set<string>()
|
||||
const scrollAnchors = new Map<string, ScrollAnchor>()
|
||||
|
||||
const onFocus = () => setFocused(true)
|
||||
@@ -152,6 +159,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
if (!enabled()) return
|
||||
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
|
||||
const sessionID = root(route.data.sessionID)
|
||||
cancelledTabs.delete(sessionID)
|
||||
history = recordSessionTabHistory(history, sessionID)
|
||||
const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined
|
||||
const tabs = openSessionTab(state().tabs, {
|
||||
@@ -160,6 +168,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
})
|
||||
if (tabs === state().tabs) return
|
||||
update((draft) => {
|
||||
if (cancelledTabs.has(sessionID)) return
|
||||
draft.tabs = openSessionTab(draft.tabs, {
|
||||
sessionID,
|
||||
title: title(sessionID, draft.tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
|
||||
@@ -266,6 +275,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
|
||||
function remove(sessionID: string, navigate: boolean) {
|
||||
const target = root(sessionID)
|
||||
cancelledTabs.add(target)
|
||||
scrollAnchors.delete(target)
|
||||
const closed = closeSessionTab(state().tabs, target)
|
||||
const selected = navigate && current() === target
|
||||
@@ -352,6 +362,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
closedTabs = result.stack
|
||||
const tabs = result.tabs
|
||||
if (!tabs || !result.sessionID) return
|
||||
cancelledTabs.delete(result.sessionID)
|
||||
update((draft) => {
|
||||
draft.tabs = tabs
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user