Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton c478ad52a9 fix(session): show interrupt progress immediately 2026-08-12 14:48:54 -04:00
10 changed files with 268 additions and 45 deletions
@@ -402,6 +402,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
},
submit: {
stopping,
pending: submission.stopping,
working,
onSubmit: () => void submission.handleSubmit(new Event("submit")),
onStop: () => void submission.abort(),
+48 -34
View File
@@ -261,6 +261,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
() => prompt.capture(),
Math.floor(Math.random() * EXAMPLES.length),
)
createEffect(() => {
if (!working()) setStore("stopping", false)
})
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
const motion = (value: number) => ({
opacity: value,
@@ -283,9 +286,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
.join("")
return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0
})
const stopping = createMemo(() => working() && blank())
const stopAction = createMemo(() => working() && blank())
const tip = () => {
if (stopping()) {
if (store.stopping) return <span>{language.t("prompt.action.stop")}...</span>
if (stopAction()) {
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
@@ -1198,36 +1202,46 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return permission.isAutoAccepting(id, sdk().directory)
})
const { abort, handleSubmit } =
props.submission ??
createPromptSubmit({
prompt,
info,
imageAttachments,
commentCount,
autoAccept: () => accepting(),
mode: () => store.mode,
working,
editor: () => editorRef,
queueScroll,
promptLength,
addToHistory,
resetHistoryNavigation: () => {
resetHistoryNavigation(true)
},
setMode: (mode) => setStore("mode", mode),
setPopover: (popover) => {
if (!popover) return closePopover()
setStore({ popover, slashMenu: false, slashMenuQuery: "" })
},
newSessionWorktree: () => props.newSessionWorktree,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
shouldQueue: props.shouldQueue,
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
model: props.controls.model.selection,
const {
abort: requestAbort,
handleSubmit,
stopping: requestStopping,
} = props.submission ??
createPromptSubmit({
prompt,
info,
imageAttachments,
commentCount,
autoAccept: () => accepting(),
mode: () => store.mode,
working,
editor: () => editorRef,
queueScroll,
promptLength,
addToHistory,
resetHistoryNavigation: () => {
resetHistoryNavigation(true)
},
setMode: (mode) => setStore("mode", mode),
setPopover: (popover) => {
if (!popover) return closePopover()
setStore({ popover, slashMenu: false, slashMenuQuery: "" })
},
newSessionWorktree: () => props.newSessionWorktree,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
shouldQueue: props.shouldQueue,
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
model: props.controls.model.selection,
})
const abort = () => {
if (store.stopping || requestStopping?.()) return Promise.resolve()
setStore("stopping", true)
return Promise.resolve(requestAbort()).finally(() => {
if (working()) setStore("stopping", false)
})
}
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
@@ -1579,12 +1593,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<IconButton
data-action="prompt-submit"
type="submit"
disabled={!working() && blank()}
disabled={store.stopping || (!working() && blank())}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
icon={stopAction() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
variant="primary"
class="size-8"
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
aria-label={stopAction() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
@@ -8,6 +8,7 @@ export type PromptInputState = ReturnType<typeof usePrompt>
export type PromptInputSubmission = {
abort: () => Promise<void> | void
handleSubmit: (event: Event) => Promise<void> | void
stopping?: () => boolean
}
export type PromptInputControls = {
@@ -46,6 +46,9 @@ let selected = "/repo/worktree-a"
let variant: string | undefined
let permissionServer = "server-a"
let createSessionGate: Promise<void> | undefined
let interruptGate: Promise<void> | undefined
let interruptCalls = 0
let interruptFailure = false
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const [promptStore, setPromptStore] = createStore<PromptStore>({
@@ -121,6 +124,11 @@ const clientFor = (directory: string) => {
shell: async (input: { sessionID: string; id?: string; command: string }) => {
sentShell.push(input)
},
interrupt: async () => {
interruptCalls++
await interruptGate
if (interruptFailure) throw new Error("interrupt failed")
},
},
},
session: {
@@ -310,11 +318,74 @@ beforeEach(() => {
variant = undefined
permissionServer = "server-a"
createSessionGate = undefined
interruptGate = undefined
interruptCalls = 0
interruptFailure = false
serverSessionSyncs = 0
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
})
describe("prompt submit worktree selection", () => {
test("reports stopping immediately and suppresses duplicate interrupts", async () => {
params = { id: "session-1" }
let release = () => {}
interruptGate = new Promise<void>((resolve) => {
release = resolve
})
let working = true
const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => working,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: () => 0,
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
})
const first = submit.abort()
const second = submit.abort()
expect(submit.stopping()).toBe(true)
expect(interruptCalls).toBe(1)
release()
await Promise.all([first, second])
working = false
expect(submit.stopping()).toBe(false)
})
test("clears stopping when the interrupt request fails", async () => {
params = { id: "session-1" }
interruptFailure = true
const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => true,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: () => 0,
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
})
await submit.abort()
expect(submit.stopping()).toBe(false)
})
test("reads the latest worktree accessor value per submit", async () => {
const submit = createPromptSubmit({
prompt,
@@ -4,7 +4,7 @@ import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { batch, startTransition, type Accessor } from "solid-js"
import { batch, createSignal, startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
@@ -263,6 +263,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
const [stopping, setStopping] = createSignal(false)
const isStopping = () => {
if (input.working()) return stopping()
setStopping(false)
return false
}
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
const errorMessage = (err: unknown) => {
@@ -276,8 +282,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
const abort = async () => {
if (isStopping()) return
const sessionID = params.id
if (!sessionID) return Promise.resolve()
setStopping(true)
serverSync().session.set("todo", sessionID, [])
@@ -289,11 +297,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
queued.abort.abort()
queued.cleanup()
pending.delete(key)
setStopping(false)
return Promise.resolve()
}
return sdk()
.api.session.interrupt({ sessionID })
.catch(() => {})
.catch(() => setStopping(false))
}
const restoreCommentItems = (
@@ -649,5 +658,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return {
abort,
handleSubmit,
stopping: isStopping,
}
}
@@ -12,6 +12,7 @@ export type PromptInputTransientState = {
draggingType: "image" | "@mention" | null
mode: "normal" | "shell"
applyingHistory: boolean
stopping: boolean
}
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
@@ -24,6 +25,7 @@ function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTr
draggingType: null,
mode: "normal",
applyingHistory: false,
stopping: false,
})
}
@@ -38,6 +40,7 @@ export function createPromptInputTransientState(identity: Accessor<unknown>, pla
draggingType: null,
mode: "normal",
applyingHistory: false,
stopping: false,
})
createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))
+100 -3
View File
@@ -28,7 +28,7 @@ import { EventTable } from "@opencode-ai/core/event/sql"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Form } from "@opencode-ai/core/form"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { SessionEvent } from "@opencode-ai/core/session/event"
@@ -355,6 +355,18 @@ const pluginSupervisor = Layer.succeed(
flush: Effect.suspend(() => pluginFlushHook),
}),
)
let snapshotCaptureHook: () => Effect.Effect<Snapshot.ID | undefined> = () => Effect.succeed(undefined)
let snapshotFilesHook: (input: Snapshot.CompareInput) => Effect.Effect<readonly RelativePath[], Snapshot.Error> = () =>
Effect.succeed([])
const snapshots = Layer.succeed(
Snapshot.Service,
Snapshot.Service.of({
capture: () => snapshotCaptureHook(),
files: (input) => snapshotFilesHook(input),
diff: () => Effect.succeed([]),
restore: () => Effect.void,
}),
)
const promptCatalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.succeed(undefined),
@@ -370,7 +382,7 @@ const promptCatalog = Layer.mock(Catalog.Service, {
},
})
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[Snapshot.node, Snapshot.noopLayer],
[Snapshot.node, snapshots],
[LayerNodePlatform.llmClient, client],
[SessionRunnerModel.node, models],
[InstructionBuiltIns.node, systemContext],
@@ -437,7 +449,7 @@ const it = testEffect(
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillInstructions.node, skillInstructions],
[ReferenceInstructions.node, referenceInstructions],
[Snapshot.node, Snapshot.noopLayer],
[Snapshot.node, snapshots],
[SessionExecution.node, execution],
[Config.node, config],
[PluginSupervisor.node, pluginSupervisor],
@@ -493,6 +505,8 @@ const setup = Effect.gen(function* () {
systemLoadHook = Effect.void
modelResolveHook = Effect.void
pluginFlushHook = Effect.void
snapshotCaptureHook = () => Effect.succeed(undefined)
snapshotFilesHook = () => Effect.succeed([])
currentModel = model
skillBaselines.clear()
toolBarrier = undefined
@@ -3741,6 +3755,89 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("waits for the end snapshot before interrupted settlement", () =>
Effect.gen(function* () {
const session = yield* setup
const endCaptureStarted = yield* Deferred.make<void>()
const releaseEndCapture = yield* Deferred.make<void>()
const runSettled = yield* Deferred.make<void>()
const interruptSettled = yield* Deferred.make<void>()
let captures = 0
snapshotCaptureHook = () => {
captures++
if (captures === 1) return Effect.succeed(Snapshot.ID.make("snapshot-start"))
return Deferred.succeed(endCaptureStarted, undefined).pipe(
Effect.andThen(Deferred.await(releaseEndCapture)),
Effect.as(Snapshot.ID.make("snapshot-end")),
)
}
snapshotFilesHook = () => Effect.succeed([RelativePath.make("changed.txt")])
yield* admit(session, "Interrupt during end snapshot")
const stream = yield* TestLLM.gate
const run = yield* session
.resume(sessionID)
.pipe(Effect.ensuring(Deferred.succeed(runSettled, undefined)), Effect.forkChild)
yield* stream.started
const interrupt = yield* session
.interrupt(sessionID)
.pipe(Effect.ensuring(Deferred.succeed(interruptSettled, undefined)), Effect.forkChild)
yield* Deferred.await(endCaptureStarted)
expect(yield* Deferred.isDone(interruptSettled)).toBe(false)
expect(yield* Deferred.isDone(runSettled)).toBe(false)
yield* Deferred.succeed(releaseEndCapture, undefined)
yield* Fiber.join(interrupt)
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({
finish: "error",
error: { type: "aborted", message: "Step interrupted" },
snapshot: {
start: "snapshot-start",
end: "snapshot-end",
files: ["changed.txt"],
},
})
}),
)
it.effect("waits for unrelated database transactions before interrupted settlement", () =>
Effect.gen(function* () {
const session = yield* setup
const { db } = yield* Database.Service
const transactionStarted = yield* Deferred.make<void>()
const releaseTransaction = yield* Deferred.make<void>()
const interruptSettled = yield* Deferred.make<void>()
yield* admit(session, "Interrupt during database contention")
const stream = yield* TestLLM.gate
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
const transaction = yield* db
.transaction(() =>
Deferred.succeed(transactionStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseTransaction))),
)
.pipe(Effect.forkChild)
yield* Deferred.await(transactionStarted)
const interrupt = yield* session
.interrupt(sessionID)
.pipe(Effect.ensuring(Deferred.succeed(interruptSettled, undefined)), Effect.forkChild)
yield* Effect.yieldNow
expect(yield* Deferred.isDone(interruptSettled)).toBe(false)
yield* Deferred.succeed(releaseTransaction, undefined)
yield* Fiber.join(transaction)
yield* Fiber.join(interrupt)
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({
finish: "error",
error: { type: "aborted", message: "Step interrupted" },
})
}),
)
it.effect("durably fails blocked local tools when interrupted while awaiting settlement", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -257,6 +257,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
<PromptInputV2SubmitButton
mode={state.mode}
stopping={view.submit.stopping()}
pending={view.submit.pending?.() ?? false}
disabled={!props.controller.canSubmit()}
sendLabel={i18n.t("ui.promptInput.send")}
stopLabel={i18n.t("ui.promptInput.stop")}
@@ -672,6 +673,7 @@ export function PromptInputV2Popover(props: {
export function PromptInputV2SubmitButton(props: {
mode: PromptInputV2Mode
stopping: boolean
pending: boolean
disabled: boolean
sendLabel: string
stopLabel: string
@@ -682,12 +684,12 @@ export function PromptInputV2SubmitButton(props: {
<TooltipV2
placement="top"
inactive={!props.stopping && props.disabled}
value={props.stopping ? props.stopLabel : props.sendLabel}
value={props.pending ? `${props.stopLabel}...` : props.stopping ? props.stopLabel : props.sendLabel}
>
<IconButton
data-action="prompt-submit"
type="button"
disabled={!props.stopping && props.disabled}
disabled={props.pending || (!props.stopping && props.disabled)}
tabIndex={props.mode === "normal" ? undefined : -1}
icon={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
variant="primary"
@@ -36,6 +36,7 @@ export type PromptInputV2ViewConfig = {
variant?: PromptInputV2SelectControl
submit: {
stopping: Accessor<boolean>
pending?: Accessor<boolean>
working?: Accessor<boolean>
onSubmit: () => void
onStop: () => void
+27 -4
View File
@@ -167,6 +167,16 @@ export function Prompt(props: PromptProps) {
const dialog = useDialog()
const toast = useToast()
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
const [stoppingSession, setStoppingSession] = createSignal<string>()
const stopping = createMemo(() => stoppingSession() === props.sessionID && status() === "running")
createEffect(
on(
() => [props.sessionID, status()] as const,
([, current]) => {
if (current === "idle") setStoppingSession(undefined)
},
),
)
const history = usePromptHistory()
const stash = usePromptStash()
const keymap = Keymap.use()
@@ -466,7 +476,7 @@ export function Prompt(props: PromptProps) {
name: "session.interrupt",
category: "Session",
palette: undefined,
enabled: status() === "running",
enabled: status() === "running" && !stopping(),
run: () => {
if (auto()?.visible) return
if (!input.focused) return
@@ -484,9 +494,12 @@ export function Prompt(props: PromptProps) {
}, 5000)
if (store.interrupt >= 2) {
void client.api.session.interrupt({
sessionID: props.sessionID,
})
setStoppingSession(props.sessionID)
void client.api.session
.interrupt({
sessionID: props.sessionID,
})
.catch(() => setStoppingSession(undefined))
setStore("interrupt", 0)
}
dialog.clear()
@@ -1797,6 +1810,16 @@ export function Prompt(props: PromptProps) {
<Slot path="prompt.footer.status" input={footerInput()}>
<box flexGrow={1} flexShrink={1} minWidth={0}>
<Switch>
<Match when={stopping()}>
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
<box marginLeft={1}>
<Show when={config.animations ?? true} fallback={<text fg={theme.text.subdued}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<text fg={theme.text.subdued}>Stopping...</text>
</box>
</Match>
<Match when={status() === "running"}>
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
<box marginLeft={1}>