Skip to content

Commit cc9588f

Browse files
deepagent-aiclaude
andcommitted
fix(audit): durable consumer lifecycle, tab restore, steer multipart, task pagination and interruption UI
Audit P1/P2 fixes from review: - V4 durable consumer group register/unregister lifecycle with cleanup on flag change - task interrupted state recognized in UI (state:interrupted + legacy boolean compat) - task_read cursor pushed to MessageV2.page for proper pagination beyond 101 messages - server-aware tab restore persists active tab key, not store[0] ordering - busy steer preserves all prompt parts (file/image/agent) with server-minted canonical ID - steer correlationID idempotency: same payload returns stored row, different payload returns 409 - terminal host close routing: side panel closes rightPanel not bottom dock - terminal lifecycle race: closeRequest gates before auto-create to prevent restart Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6906ee6 commit cc9588f

21 files changed

Lines changed: 626 additions & 147 deletions

packages/app/src/app.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import { PromptProvider } from "@/context/prompt"
4545
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
4646
import { SettingsProvider } from "@/context/settings"
4747
import { TerminalProvider } from "@/context/terminal"
48-
import { TabsProvider, useTabs } from "@/context/tabs"
48+
import { startupTab, TabsProvider, useTabs } from "@/context/tabs"
4949
import { WslServersProvider } from "@/wsl/context"
5050
import DirectoryLayout from "@/pages/directory-layout"
5151
import Layout from "@/pages/layout"
@@ -144,20 +144,22 @@ function SessionProviders(props: ParentProps) {
144144

145145
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
146146
const tabs = useTabs()
147+
const server = useServer()
147148
const navigate = useNavigate()
148149
const location = useLocation()
149150

150-
// On startup: as soon as persisted tabs are loaded (local disk, no HTTP calls),
151-
// navigate to the last visited project's session list. This matches opencode's approach —
152-
// use local/cached data to drive the first frame, let the server data fill in asynchronously.
153-
// We navigate to /:dir/session (no specific session ID) so autoselecting can handle
154-
// the final session selection independently without conflicting navigation.
151+
// Restore the explicitly persisted active tab. A cross-server directory is never
152+
// navigated until the target server has become active.
155153
createEffect(() => {
156154
if (!tabs.ready()) return
157155
if (location.pathname !== "/") return
158-
const first = tabs.store[0]
159-
if (!first) return
160-
navigate(`/${first.dirBase64}/session`, { replace: true })
156+
const tab = startupTab(tabs.store, tabs.active.key, server.list)
157+
if (!tab) return
158+
if (server.key !== tab.server) {
159+
server.setActive(tab.server)
160+
return
161+
}
162+
navigate(`/${tab.dirBase64}/session`, { replace: true })
161163
})
162164

163165
return (

packages/app/src/components/terminal.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ export interface TerminalProps extends ComponentProps<"div"> {
3535
runtimeId?: string
3636
onSubmit?: () => void
3737
onStatusChange?: (status: TerminalStatus, error?: TerminalFailure) => void
38+
/** When true (restored PTY), show the terminal immediately after xterm initialises
39+
* instead of waiting for the WebSocket handshake. The WebSocket still connects in
40+
* the background; input typed before it's ready is buffered. */
41+
optimisticReady?: boolean
3842
}
3943

4044
let shared: Promise<{ mod: typeof import("ghostty-web"); ghostty: Ghostty }> | undefined
@@ -159,6 +163,7 @@ export const Terminal = (props: TerminalProps) => {
159163
"runtimeId",
160164
"onSubmit",
161165
"onStatusChange",
166+
"optimisticReady",
162167
])
163168
const id = local.pty.ptyId
164169
let ws: WebSocket | undefined
@@ -435,6 +440,8 @@ export const Terminal = (props: TerminalProps) => {
435440
})
436441
cleanups.push(() => disposeIfDisposable(onResize))
437442
const onData = t.onData((data) => {
443+
// When optimisticReady is active the buffering handler below takes over.
444+
if (local.optimisticReady) return
438445
if (ws?.readyState === WebSocket.OPEN) ws.send(data)
439446
})
440447
cleanups.push(() => disposeIfDisposable(onData))
@@ -456,6 +463,28 @@ export const Terminal = (props: TerminalProps) => {
456463
scheduleSize(t.cols, t.rows)
457464
startResize()
458465

466+
// For restored PTYs: show the terminal surface immediately after xterm is
467+
// initialised instead of waiting for the full WebSocket handshake (which can
468+
// take 2-4 s). Input typed before the socket opens is buffered and flushed
469+
// once the connection is established.
470+
let inputBuffer = local.optimisticReady ? "" : undefined
471+
if (local.optimisticReady) {
472+
markReady()
473+
// Intercept onData to buffer keystrokes until the WebSocket is open.
474+
const onDataOpt = t.onData((data) => {
475+
if (ws?.readyState === WebSocket.OPEN) {
476+
if (inputBuffer) {
477+
ws.send(inputBuffer)
478+
inputBuffer = undefined
479+
}
480+
ws.send(data)
481+
} else {
482+
inputBuffer = (inputBuffer ?? "") + data
483+
}
484+
})
485+
cleanups.push(() => disposeIfDisposable(onDataOpt))
486+
}
487+
459488
const once = { value: false }
460489
const decoder = new TextDecoder()
461490

packages/app/src/context/layout.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -938,6 +938,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
938938
const session = key()
939939
const current = store.sessionView[session]
940940
const bottom = next.bottomPanel
941+
// Keep the project-level terminal flag in sync so that switching to a
942+
// session that has never explicitly opened the panel doesn't close it.
943+
// `bottomPanel` memo falls back to `store.terminal?.opened` for new
944+
// sessions — this makes that fallback reflect the current user intent.
945+
setTerminalOpened(bottom?.opened === true && bottom?.activeView === "terminal")
941946
if (!current) {
942947
setStore("sessionView", session, { scroll: {}, bottomPanel: bottom, rightPanelMode: next.rightPanelMode })
943948
return
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { ServerConnection } from "./server"
3+
import { startupTab, tabKey, type Tab } from "./tabs"
4+
5+
const primary = ServerConnection.Key.make("http://primary")
6+
const secondary = ServerConnection.Key.make("http://secondary")
7+
8+
const servers = [
9+
{ type: "http" as const, http: { url: "http://primary" } },
10+
{ type: "http" as const, http: { url: "http://secondary" } },
11+
]
12+
13+
const first: Tab = {
14+
type: "session",
15+
server: primary,
16+
dirBase64: "L3ByaW1hcnk=",
17+
sessionId: "first",
18+
}
19+
const lastActive: Tab = {
20+
type: "session",
21+
server: secondary,
22+
dirBase64: "L3NlY29uZGFyeQ==",
23+
sessionId: "last-active",
24+
}
25+
26+
describe("startup tab recovery", () => {
27+
test("restores the persisted active tab rather than the first tab", () => {
28+
expect(startupTab([first, lastActive], tabKey(lastActive), servers)).toBe(lastActive)
29+
})
30+
31+
test("falls back to the first stored tab when the active key is stale", () => {
32+
expect(startupTab([first, lastActive], "stale", servers)).toBe(first)
33+
})
34+
35+
test("does not restore a target whose server is unavailable", () => {
36+
expect(startupTab([first, lastActive], tabKey(lastActive), [servers[0]])).toBeUndefined()
37+
})
38+
})

packages/app/src/context/tabs.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ export type Tab = SessionTab
2020
export const tabHref = (tab: Tab) => `/${tab.dirBase64}/session/${tab.sessionId}`
2121
export const tabKey = (tab: Tab) => `${tab.server}\n${tabHref(tab)}`
2222

23+
export function activeTab(tabs: Tab[], key?: string) {
24+
return tabs.find((tab) => tabKey(tab) === key) ?? tabs[0]
25+
}
26+
27+
export function startupTab(tabs: Tab[], activeKey: string | undefined, servers: ServerConnection.Any[]) {
28+
const tab = activeTab(tabs, activeKey)
29+
if (!tab) return
30+
return servers.some((server) => ServerConnection.key(server) === tab.server) ? tab : undefined
31+
}
32+
2333
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
2434
const dirBase64 = base64Encode(session.directory)
2535
return tabs.some(
@@ -47,6 +57,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
4757
},
4858
createStore<Tab[]>([]),
4959
)
60+
const [active, setActive, _activePersist, activeReady] = persisted(
61+
Persist.global("tabs.active"),
62+
createStore({ key: undefined as string | undefined }),
63+
)
5064

5165
const params = useParams()
5266
const navigate = useNavigate()
@@ -61,6 +75,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
6175
})
6276

6377
const navigateTab = (tab: Tab) => {
78+
setActive("key", tabKey(tab))
6479
const href = tabHref(tab)
6580
if (tab.server === server.key) {
6681
navigate(href)
@@ -96,13 +111,20 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
96111
}),
97112
)
98113
if (nextTab) navigateTab(nextTab)
99-
else navigate("/")
114+
else {
115+
setActive("key", undefined)
116+
navigate("/")
117+
}
100118
}).finally(() => closing.delete(key))
101119
},
102120
removeServer(key: ServerConnection.Key) {
103121
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
122+
if (active.key?.startsWith(`${key}\n`)) setActive("key", undefined)
104123
if (server.key === key) navigate("/")
105124
},
125+
setActive(tab: Tab) {
126+
navigateTab(tab)
127+
},
106128
removeSessions: (input: SessionTabsRemovedDetail) => {
107129
void startTransition(() => {
108130
setStore(
@@ -145,6 +167,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
145167
},
146168
}
147169

148-
return { ...actions, store, ready }
170+
return { ...actions, active, ready: () => ready() && activeReady(), store }
149171
},
150172
})

packages/app/src/context/terminal.tsx

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export type LocalPTY = {
5252
titleNumber: number
5353
status: TerminalStatus
5454
error?: TerminalFailure
55+
/** True when restored from cross-project navigation cache; cleared on first ready. */
56+
restored?: boolean
5557
}
5658

5759
export type TerminalStore = {
@@ -313,6 +315,14 @@ function removeTerminalPersistence(
313315
type TerminalSession = ReturnType<typeof createWorkspaceTerminalSession>
314316
const sessions = new Set<{ dir: string; scope: ServerScopeValue; value: TerminalSession }>()
315317

318+
/** Per-directory PTY snapshot preserved across project switches so PTYs survive navigation. */
319+
interface TerminalPtySnapshot {
320+
ptys: Array<Pick<LocalPTY, "id" | "ptyId" | "title" | "titleNumber">>
321+
root: PaneNode
322+
focusedPaneId: string
323+
}
324+
const directoryTerminalCache = new Map<string, { bottom: TerminalPtySnapshot | null; side: TerminalPtySnapshot | null }>()
325+
316326
export function clearWorkspaceTerminals(
317327
dir: string,
318328
sessionIDs?: string[],
@@ -535,6 +545,37 @@ function createWorkspaceTerminalSession(
535545
resetRuntime() {
536546
reset()
537547
},
548+
snapshot(): TerminalPtySnapshot | null {
549+
if (store.all.length === 0) return null
550+
return {
551+
ptys: store.all.map((pty) => ({
552+
id: pty.id,
553+
ptyId: pty.ptyId,
554+
title: pty.title,
555+
titleNumber: pty.titleNumber,
556+
})),
557+
root: root(),
558+
focusedPaneId: focusedPaneId(),
559+
}
560+
},
561+
restore(snapshot: TerminalPtySnapshot) {
562+
batch(() => {
563+
setStore(
564+
"all",
565+
snapshot.ptys.map((p) => ({
566+
id: p.id,
567+
ptyId: p.ptyId,
568+
title: p.title,
569+
titleNumber: p.titleNumber,
570+
status: "connecting" as TerminalStatus,
571+
error: undefined,
572+
restored: true,
573+
})),
574+
)
575+
setRootSignal(clonePaneTree(snapshot.root))
576+
setFocusedPaneId(snapshot.focusedPaneId)
577+
})
578+
},
538579
clear() {
539580
const ptyIds = store.all.map((pty) => pty.ptyId)
540581
reset()
@@ -633,7 +674,7 @@ function createWorkspaceTerminalSession(
633674
setStatus(id: string, ptyId: string, status: TerminalStatus, error?: TerminalFailure) {
634675
const index = store.all.findIndex((pty) => pty.id === id && pty.ptyId === ptyId)
635676
if (index === -1) return
636-
setStore("all", index, { status, error: status === "ready" ? undefined : error })
677+
setStore("all", index, { status, error: status === "ready" ? undefined : error, ...(status === "ready" ? { restored: false } : {}) })
637678
},
638679
update(input: Partial<LocalPTY> & { id: string }) {
639680
if (input.title === undefined) return
@@ -772,6 +813,16 @@ const { use: useTerminalDual, provider: TerminalProvider } = createSimpleContext
772813
bottomSession = createWorkspaceTerminalSession(sdk, runtime)
773814
sideSession = createWorkspaceTerminalSession(sdk, runtime)
774815

816+
// Restore previously-saved PTYs when returning to a directory.
817+
// The cached snapshot was written by onCleanup — PTYs stayed alive on the
818+
// server and just need to be re-registered in local reactive state.
819+
const cached = directoryTerminalCache.get(sdk.directory)
820+
if (cached) {
821+
directoryTerminalCache.delete(sdk.directory)
822+
if (cached.bottom) bottomSession.restore(cached.bottom)
823+
if (cached.side) sideSession.restore(cached.side)
824+
}
825+
775826
const bottomReg = { dir: sdk.directory, scope, value: bottomSession }
776827
const sideReg = { dir: sdk.directory, scope, value: sideSession }
777828
sessions.add(bottomReg)
@@ -786,10 +837,14 @@ const { use: useTerminalDual, provider: TerminalProvider } = createSimpleContext
786837
onCleanup(() => clearInterval(timer))
787838
})
788839
onCleanup(() => {
840+
// Save PTY state so terminals survive project navigation. Do NOT call
841+
// clear() here — that would DELETE PTYs on the server. The PTYs keep
842+
// running and are reconnected when the user returns to this directory.
843+
const bottomSnap = bottomSession?.snapshot() ?? null
844+
const sideSnap = sideSession?.snapshot() ?? null
845+
directoryTerminalCache.set(sdk.directory, { bottom: bottomSnap, side: sideSnap })
789846
sessions.delete(bottomReg)
790847
sessions.delete(sideReg)
791-
bottomSession?.clear()
792-
sideSession?.clear()
793848
})
794849

795850
return { bottom: bottomSession, side: sideSession }

packages/app/src/pages/session/session-side-panel.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { IdeFileEditor } from "@/pages/session/ide-file-editor"
2323
import { setSessionHandoff } from "@/pages/session/handoff"
2424
import { useSessionLayout } from "@/pages/session/session-layout"
2525
import { SidePanelSubagents } from "@/pages/session/side-panel-subagents"
26+
import { isSubagentInterrupted } from "@/pages/session/subagent-state"
2627
import { SidePanelBrowser } from "@/pages/session/side-panel-browser"
2728
import { SidePanelWorktree } from "@/pages/session/side-panel-worktree"
2829
import { SidePanelDebug } from "@/pages/session/side-panel-debug"
@@ -135,11 +136,7 @@ export function SessionSidePanel(props: {
135136
() => subagentChildren().filter((s) => sync.data.session_working(s.id)).length,
136137
)
137138
const interruptedSubagentCount = createMemo(
138-
() =>
139-
subagentChildren().filter((s) => {
140-
const sub = (s.metadata?.["deepagent"] as { subagent?: { interrupted?: boolean } } | undefined)?.subagent
141-
return sub?.interrupted === true
142-
}).length,
139+
() => subagentChildren().filter(isSubagentInterrupted).length,
143140
)
144141
const subagentAttentionCount = createMemo(
145142
() => runningSubagentCount() + interruptedSubagentCount(),

packages/app/src/pages/session/side-panel-subagents.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useNavigate, useParams } from "@solidjs/router"
66
import { useSDK } from "@/context/sdk"
77
import { fetchCapabilities } from "@/components/deepagent/panel-goal.api"
88
import { OversightDashboard } from "@/components/deepagent/oversight-dashboard"
9+
import { isSubagentInterrupted } from "./subagent-state"
910

1011
// Phase 2 (§3): SidePanelSubagents is now the single "子Agent监督" entry for the right rail.
1112
// It holds a `selectedSessionID` to track which subagent is being inspected; that selection
@@ -23,6 +24,7 @@ import { OversightDashboard } from "@/components/deepagent/oversight-dashboard"
2324
// (scope+route SessionStateKey): that never equals a child's parentID, so the list was always
2425
// empty. Resolving it internally here (like SidePanelIM / SidePanelDebug do) keeps the contract
2526
// simple and immune to that mismatch.
27+
2628
export const SidePanelSubagents: Component<{ onClose: () => void }> = (props) => {
2729
const sync = useSync()
2830
const language = useLanguage()
@@ -56,10 +58,7 @@ export const SidePanelSubagents: Component<{ onClose: () => void }> = (props) =>
5658
const sub = (child.metadata?.["deepagent"] as { subagent?: { finished?: boolean } } | undefined)?.subagent
5759
return sub?.finished === true
5860
}
59-
const isInterrupted = (child: { metadata?: Record<string, unknown> }): boolean => {
60-
const sub = (child.metadata?.["deepagent"] as { subagent?: { interrupted?: boolean } } | undefined)?.subagent
61-
return sub?.interrupted === true
62-
}
61+
const isInterrupted = isSubagentInterrupted
6362
const statusOf = (
6463
child: { id: string; metadata?: Record<string, unknown> },
6564
): "running" | "finished" | "interrupted" | "idle" => {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { isSubagentInterrupted } from "./subagent-state"
3+
4+
describe("isSubagentInterrupted", () => {
5+
test("recognizes durable state and legacy boolean interruption markers", () => {
6+
expect(isSubagentInterrupted({ deepagent: { subagent: { state: "interrupted" } } })).toBe(true)
7+
expect(isSubagentInterrupted({ deepagent: { subagent: { interrupted: true } } })).toBe(true)
8+
expect(isSubagentInterrupted({ deepagent: { subagent: { state: "finished" } } })).toBe(false)
9+
})
10+
})
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
type SubagentMetadata = {
2+
state?: string
3+
interrupted?: boolean
4+
}
5+
6+
/** Supports durable state markers and legacy boolean interruption markers. */
7+
export const isSubagentInterrupted = (metadata?: Record<string, unknown>) => {
8+
const subagent = (metadata?.["deepagent"] as { subagent?: SubagentMetadata } | undefined)?.subagent
9+
return subagent?.state === "interrupted" || subagent?.interrupted === true
10+
}

0 commit comments

Comments
 (0)