diff --git a/README.md b/README.md
index 46622790..dfe5d0d7 100644
--- a/README.md
+++ b/README.md
@@ -15,25 +15,53 @@
---
-DeepAgent Code is an AI coding agent built on persistent document memory. It keeps [opencode](https://github.com/sst/opencode)'s runtime foundations and adds a control plane for **durable knowledge**, **cross-session memory**, **context assembly**, **learning lifecycle**, and **runtime intelligence**.
+DeepAgent Code is an AI coding agent built on persistent document memory. It keeps [opencode](https://github.com/sst/opencode)'s runtime foundations and adds a control plane so the agent behaves less like a one-shot chat and more like a teammate that remembers your project, sharpens vague asks, and goes deep on hard problems.
-## What Makes It Different
+The features below start from a real need — something a plain coding agent, opencode included, leaves on the table — and work down to the architecture we built to serve it.
-**Persistent document system** — Knowledge, decisions, diagnostics, and learnings are stored as typed documents in a searchable graph. The agent builds understanding across sessions instead of starting from scratch each time.
+## What You Can Do
-**Project memory sharing** — Multiple conversations within the same project share knowledge, coding patterns, common pitfalls, and build commands. What one session learns becomes available to the next.
+### Keep one conversation going indefinitely
-**AI IDE microservice** — Query code by symbol name and intent (not file:line coordinates). Get definitions, references, call chains, type hierarchies, and diagnostics in one call. Built on LSP with 38 language servers.
+**The need:** Long tasks overflow the context window. Most agents respond by truncating history or summarizing everything at a threshold — so mid-task the agent forgets a decision you made an hour ago, or the window fills with stale tool output and quality falls off a cliff.
-**Preset MCP catalog** — Curated MCP servers for Git platforms, file search, read-only databases, and browser automation. Risk tiers derived at runtime from catalog structure, not user config.
+**What DeepAgent does:** Your conversation is treated as a continuously maintained work state, not a growing chat log. Before every turn, the agent rebuilds a working set — the task anchor, the most recent exchanges verbatim, active file references, and only the older facts that are relevant right now — while the full history is archived durably and stays queryable. The working set is held to a hard fraction of the model window, so there's always room for the model to actually think and respond. You just keep talking; the agent keeps focus.
-**Domain packs** — Specialized knowledge packages for specific domains (GPU kernels, React, backend APIs, security, testing). Each pack contains typed documents (strategies, methodologies, knowledge, skills) + validation/diagnostic adapters. Packs compose: activate multiple domains for your task.
+### Switch windows, fork freely, never lose memory
-**Learning lifecycle** — After completing work, the agent can generate candidate memories, facts, strategies, and methodologies. Evidence and approval gates control what gets persisted.
+**The need:** You want to try two approaches, or hand the work to a fresh conversation, without starting from amnesia and without polluting the original thread.
-**Work strength ladder** — `general`, `high`, `xhigh`, `max`, `ultra` scale capability without breaking contracts. Higher strengths add control-plane abilities (multi-agent orchestration, adversarial validation) on top of base behavior.
+**What DeepAgent does:** Fork any conversation from a chosen message. The fork opens carrying the parent's memory up to that point, shows a full-width "derived from" marker at the top of its transcript, and nests folder-style under its origin in the session tree (subagents and forks alike, up to three levels deep). Knowledge flows up a scope hierarchy — what one session learns can be promoted to the whole project, and cross-project preferences live at the user-global layer — so switching windows is a clean handoff, not a reset.
-**Scenario modes** — `direct` executes immediately; `wish` refines intent first, shows draft plan, waits for confirmation before automation.
+### Ask roughly, and let the agent sharpen it
+
+**The need:** A half-formed prompt gets a half-useful answer, and you don't always know how to phrase what you want.
+
+**What DeepAgent does:** Two scenario modes on the composer. **Direct** sends your prompt as-is — you own the wording. **Intelligence** refines a rough ask into a sharper prompt, surfaces a draft plan and decision suggestions, and waits for your confirmation before it automates anything. You decide how much the agent shapes the request.
+
+### Go deep on genuinely hard problems
+
+**The need:** Complex work — an architecture decision, a tricky migration, a subtle bug — needs more than a single confident pass. It needs research, a second opinion, and someone actively trying to poke holes.
+
+**What DeepAgent does:** At higher work strengths the primary agent decomposes the task, fans it out to focused subagents that research modules in parallel, synthesizes their findings, and then runs independent reviewers whose job is to *break* the plan rather than agree with it. Fan-out is bounded by a configurable concurrency ceiling, and live subagents surface in a session side panel and inline in the transcript so you can watch and jump into any of them.
+
+### Chat with your team and your agents in one place
+
+**The need:** Coordinating with teammates and driving agents usually happens in two different tools.
+
+**What DeepAgent does:** A per-project group chat lives in the session side panel. @mention an agent as a chat member and it runs the full agent loop — query code, generate, fix — pulling project knowledge and recent messages for context, then replies inline with live progress streaming.
+
+## How It Works
+
+Each capability above is served by a control-plane primitive underneath. These are the parts a plain runtime doesn't have.
+
+**Four-graph unification** — Code, knowledge, project memory, and the document graph are unified into one typed, bidirectionally-linked store. When the agent pulls context, a change to a symbol surfaces the design decisions, past diagnoses, and knowledge actually linked to it — connected context, not four disconnected keyword searches.
+
+**Domain packs** — 140+ composable knowledge packages spanning languages, frameworks, platforms (cloud, Kubernetes, CI), hardware, and business/risk domains (security, privacy, compliance). Each pack bundles typed documents (strategies, methodologies, knowledge, skills, failure dossiers) with detectors that auto-activate the right packs for your task; conflicts resolve stricter-policy-wins, and the active set is version-locked so a run is reproducible. Core stays domain-neutral — expertise is data on disk, not hardcoded.
+
+**Tiered knowledge invocation** — A monotonic strength ladder (`general → high → xhigh → max → ultra`) gates how much control-plane machinery engages. `general` stays close to the plain runtime — fast and cheap. Higher rungs progressively unlock durable knowledge, project handoff summaries, heavier strategy/methodology tiers, and multi-agent orchestration. You pay for depth only when you dial it up.
+
+**Self-learning** — After work lands, the agent proposes candidate knowledge, facts, and methodologies. Promotion is evidence-gated (a test passed, a diagnostic cleared, a validation confirmed) and user-controllable — durable knowledge is carried over deliberately, not silently guessed. Session-stable conclusions consolidate into project memory over time, so the next session starts smarter about *your* codebase.
## Installation
@@ -61,43 +89,41 @@ The agent will:
1. Use LSP to find the endpoint definition and understand its structure
2. Check project memory for existing middleware patterns
-3. Implement rate limiting following project conventions
-4. Run tests and capture diagnostics
-5. Generate a candidate memory: "This project uses express-rate-limit middleware"
+3. Activate the relevant domain packs (backend API, the project's language)
+4. Implement rate limiting following project conventions
+5. Run tests, capture diagnostics, and propose a candidate memory: "This project uses express-rate-limit middleware"
On your next session, when you ask to add rate limiting elsewhere, the agent already knows the pattern.
## Core Concepts
-**Document graph** — All persistent state lives in typed documents: `knowledge`, `strategy`, `methodology`, `skill`, `memory`, `design`, `worklog`, `diagnosis`, `eval`. Documents link to each other (supports/blocks/conflicts/validates), forming a graph.
+**Document graph** — All persistent state lives in typed documents: `knowledge`, `strategy`, `methodology`, `skill`, `memory`, `design`, `worklog`, `diagnosis`, `eval`. Documents link to each other (supports/blocks/conflicts/validates), forming a graph you can traverse.
**Scope layers** — `session-private` (current conversation), `project-shared` (all sessions in this project), `user-global` (cross-project preferences), `public-system` (built-in skills), `sealed` (audit-only, never enters context).
-**Domain packs** — Each pack (e.g., `code.frontend.react`, `code.gpu-kernel`, `risk.security`) is a bundle of typed documents + adapters. Documents include strategies (directions), methodologies (multi-step workflows), knowledge (facts), skills (executable capabilities), and failure dossiers. Packs auto-activate based on problem profile or explicit selection. Core stays domain-neutral.
-
-**Context admission** — Retrieval hits go through admission gates. Sensitive information (SSH hosts, tokens, internal paths) gets suggested but not auto-expanded into prompts.
+**Context admission** — Retrieval hits pass through admission gates. Full tool output (raw LSP dumps, diagnostics, capability indexes) is written to evidence artifacts, ref-linked and tool-only; only summaries and `file:line` snippets enter the model context. Sensitive values (SSH hosts, tokens, internal paths) are suggested, never auto-expanded.
-**Evidence-gated learning** — Learnings require evidence (test pass, diagnostic clear, validation confirmed). Candidates enter a queue; auto-merge or manual review depends on policy.
+**AI IDE microservice** — Query code by symbol name and intent (e.g. `code_intel({ symbol: "AgentGateway.open", intent: "overview" })`), not file:line coordinates. Get definitions, references, call chains, type hierarchies, and diagnostics in one call. Built on LSP with 38 language servers; degrades gracefully to grep/read when no server is configured.
-**Symbol-driven navigation** — Code intelligence tools accept symbol names (e.g., "AgentGateway.open"), not coordinates. The agent resolves names to locations internally via LSP workspace/document symbols.
+**Preset MCP catalog** — Curated MCP servers for Git platforms, file search, read-only databases, and browser automation. Risk tiers are derived at load time from the catalog template (not user config, so they can't be injected), and servers default to not-connected with write and external-fetch operations behind approval gates.
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Control Plane (DeepAgent additions) │
-│ • Document graph (persistent memory) │
+│ • Four-graph unified store (code + knowledge + memory + doc)│
+│ • Continuously maintained working state (memory + compaction)│
+│ • Domain pack system (composable, auto-activating knowledge)│
│ • Context assembly & admission gates │
-│ • Learning worker (background, non-blocking) │
-│ • Evidence & approval gates │
-│ • Work strength orchestration │
-│ • Domain pack system (composable knowledge) │
+│ • Multi-agent orchestration & adversarial review │
+│ • Evidence-gated learning & work-strength ladder │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ Runtime Foundations (from opencode) │
│ • Agent loop & tool execution │
-│ • Session & provider management │
+│ • Session, fork & provider management │
│ • MCP client runtime │
│ • Permission system │
└─────────────────────────────────────────────────────────────┘
@@ -111,7 +137,7 @@ On your next session, when you ask to add rate limiting elsewhere, the agent alr
└─────────────────────────────────────────────────────────────┘
```
-DeepAgent's control plane operates at provider-turn boundaries: it selects context before each model call and writes evidence back into the document graph afterward. It does not replace opencode's runtime—it layers on top.
+DeepAgent's control plane operates at provider-turn boundaries: it selects context before each model call and writes evidence back into the document graph afterward. It does not replace opencode's runtime — it layers on top.
## Documentation
@@ -127,22 +153,6 @@ DeepAgent Code is licensed under **AGPL-3.0-or-later**. If you modify and run it
This project is derived from [opencode](https://github.com/sst/opencode) (MIT License). See [NOTICE](NOTICE) for the upstream license and attribution. No endorsement by opencode or its contributors is implied.
-## Project Status
-
-**V3.4.1** is the first public pre-release hardening milestone. It includes:
-
-- LSP-to-AI-IDE transformation (symbol-driven code intelligence)
-- Preset MCP catalog with security model
-- License and attribution cleanup
-- Secret scan baseline
-- Design documentation consolidation
-
-**V3.5** (planned) will add:
-
-- DAP integration (debug adapter protocol for runtime intelligence)
-- PAP (performance analysis protocol for profiling: NVIDIA NCU/nsys, AMD rocprof, Intel VTune, CPU perf)
-- OS-backed credential storage for MCP servers
-
---
+ )
+}
diff --git a/packages/app/src/components/im/group-chat-panel.tsx b/packages/app/src/components/im/group-chat-panel.tsx
index 196a1079..18dd8d3b 100644
--- a/packages/app/src/components/im/group-chat-panel.tsx
+++ b/packages/app/src/components/im/group-chat-panel.tsx
@@ -16,10 +16,8 @@ export function GroupChatPanel(props: GroupChatPanelProps) {
const [agents, setAgents] = createSignal([])
const [loading, setLoading] = createSignal(true)
- const { connected, messages: realtimeMessages, agentStatuses, typingMembers, send } = useIMWebSocket(
- client,
- () => props.groupID,
- )
+ const { connected, messages: realtimeMessages, agentStatuses, agentProgress, typingMembers, send } =
+ useIMWebSocket(client, () => props.groupID)
// The single desktop user identity, as the server assigns it. Used to exclude
// our own echoed typing/read events from the UI.
@@ -111,7 +109,7 @@ export function GroupChatPanel(props: GroupChatPanelProps) {
-
+ 0}>
diff --git a/packages/app/src/components/im/message-item.tsx b/packages/app/src/components/im/message-item.tsx
index 8f23acdf..af532e10 100644
--- a/packages/app/src/components/im/message-item.tsx
+++ b/packages/app/src/components/im/message-item.tsx
@@ -1,11 +1,14 @@
import { Show } from "solid-js"
import { AgentStatusChip } from "./agent-status-chip"
+import { AgentReasoningCard } from "./agent-reasoning-card"
import { MessageMetadataCard } from "./message-metadata-card"
-import type { LocalMessage } from "@/hooks/use-im-websocket"
+import type { AgentProgressPart, LocalMessage } from "@/hooks/use-im-websocket"
interface MessageItemProps {
message: LocalMessage
agentStatus?: { agentID: string; status: string }
+ /** Live reasoning/tool/text parts for the turn this message triggered. */
+ agentProgress?: AgentProgressPart[]
}
export function MessageItem(props: MessageItemProps) {
@@ -40,6 +43,15 @@ export function MessageItem(props: MessageItemProps) {
+ 0}>
+
+
+
+
+
diff --git a/packages/app/src/components/im/message-list.tsx b/packages/app/src/components/im/message-list.tsx
index 59b7c443..4bd3e5d5 100644
--- a/packages/app/src/components/im/message-list.tsx
+++ b/packages/app/src/components/im/message-list.tsx
@@ -1,19 +1,24 @@
import { For, Show, createEffect } from "solid-js"
import { MessageItem } from "./message-item"
-import type { LocalMessage } from "@/hooks/use-im-websocket"
+import type { AgentProgressPart, LocalMessage } from "@/hooks/use-im-websocket"
interface MessageListProps {
messages: LocalMessage[]
agentStatuses: Map
+ agentProgress: Map
}
export function MessageList(props: MessageListProps) {
let containerRef: HTMLDivElement | undefined
let bottomRef: HTMLDivElement | undefined
- // Auto-scroll to bottom on new messages
+ // Auto-scroll to bottom on new messages AND as live reasoning streams in, so
+ // the agent's in-progress thinking stays in view without manual scrolling.
createEffect(() => {
- if (props.messages.length > 0 && bottomRef) {
+ // Track both signals so streaming progress updates also trigger a scroll.
+ const count = props.messages.length
+ props.agentProgress
+ if (count > 0 && bottomRef) {
bottomRef.scrollIntoView({ behavior: "smooth" })
}
})
@@ -33,6 +38,7 @@ export function MessageList(props: MessageListProps) {
)}
diff --git a/packages/app/src/components/im/types.ts b/packages/app/src/components/im/types.ts
index 8e59b67d..9366a73c 100644
--- a/packages/app/src/components/im/types.ts
+++ b/packages/app/src/components/im/types.ts
@@ -1,10 +1,10 @@
-export interface AgentDescriptor {
- id: string
- name: string
- displayName: string
- description?: string
- visible: boolean
-}
+// The IM agent descriptor is defined canonically in the core package
+// (`packages/core/src/im/mention-parser.ts`) and served verbatim by the HTTP
+// API (`AgentDescriptorResponse = AgentDescriptor`). Re-export the canonical
+// types here instead of hand-mirroring them, so the frontend can never drift
+// from the backend contract. `Trigger`, `AutonomyLevel` and `AgentLimits` are
+// the sub-schemas referenced by `AgentDescriptor`.
+export type { AgentDescriptor, AgentLimits, AutonomyLevel, Trigger } from "@deepagent-code/core/im/mention-parser"
export interface IMGroup {
id: string
diff --git a/packages/app/src/components/prompt-input/scenario-override.test.ts b/packages/app/src/components/prompt-input/scenario-override.test.ts
index f1879cfc..97fb3acf 100644
--- a/packages/app/src/components/prompt-input/scenario-override.test.ts
+++ b/packages/app/src/components/prompt-input/scenario-override.test.ts
@@ -12,50 +12,50 @@ import {
// choosing the turn's prompt pipeline mode.
describe("scenario override (D1/D3)", () => {
test("override is read back per key", () => {
- setScenarioOverride("ses_a", "wish")
- expect(getScenarioOverride("ses_a")).toBe("wish")
+ setScenarioOverride("ses_a", "intelligence")
+ expect(getScenarioOverride("ses_a")).toBe("intelligence")
setScenarioOverride("ses_a", "direct")
expect(getScenarioOverride("ses_a")).toBe("direct")
})
test("keys are isolated", () => {
- setScenarioOverride("ses_b", "wish")
+ setScenarioOverride("ses_b", "intelligence")
expect(getScenarioOverride("ses_c")).toBeUndefined()
})
test("resolve prefers session key, falls back to directory key", () => {
- setScenarioOverride("dir_x", "wish")
- // No session override yet -> directory-scoped wish applies (toggle set before session exists).
- expect(resolveScenarioOverride("ses_new", "dir_x")).toBe("wish")
+ setScenarioOverride("dir_x", "intelligence")
+ // No session override yet -> directory-scoped intelligence applies (toggle set before session exists).
+ expect(resolveScenarioOverride("ses_new", "dir_x")).toBe("intelligence")
// A session override takes precedence over the directory one.
setScenarioOverride("ses_new", "direct")
expect(resolveScenarioOverride("ses_new", "dir_x")).toBe("direct")
})
test("stop clears both session and directory overrides so the next turn uses the configured default", () => {
- setScenarioOverride("ses_d", "wish")
- setScenarioOverride("dir_d", "wish")
+ setScenarioOverride("ses_d", "intelligence")
+ setScenarioOverride("dir_d", "intelligence")
resetScenarioOnStop("ses_d", "dir_d")
// Cleared, not pinned to "direct": resolution now falls through to the configured default,
- // and a pinned session key can no longer shadow a later dir-key toggle back to wish.
+ // and a pinned session key can no longer shadow a later dir-key toggle back to intelligence.
expect(getScenarioOverride("ses_d")).toBeUndefined()
expect(getScenarioOverride("dir_d")).toBeUndefined()
expect(resolveScenarioOverride("ses_d", "dir_d")).toBeUndefined()
})
- test("after stop, re-toggling wish on the directory key re-engages wish at submit time", () => {
- // Repro of the 'exit wish -> can never re-enter' bug: previously stop pinned the SESSION key to
+ test("after stop, re-toggling intelligence on the directory key re-engages intelligence at submit time", () => {
+ // Repro of the 'exit intelligence -> can never re-enter' bug: previously stop pinned the SESSION key to
// "direct", which submit resolves before the dir key, so the toggle (dir key only) was shadowed.
- setScenarioOverride("ses_f", "wish")
+ setScenarioOverride("ses_f", "intelligence")
resetScenarioOnStop("ses_f", "dir_f")
- setScenarioOverride("dir_f", "wish") // user re-selects wish via the toggle
- expect(resolveScenarioOverride("ses_f", "dir_f")).toBe("wish")
+ setScenarioOverride("dir_f", "intelligence") // user re-selects intelligence via the toggle
+ expect(resolveScenarioOverride("ses_f", "dir_f")).toBe("intelligence")
})
test("listeners are notified when overrides change", () => {
let count = 0
const unsubscribe = subscribeScenarioOverride(() => count++)
- setScenarioOverride("ses_e", "wish")
+ setScenarioOverride("ses_e", "intelligence")
resetScenarioOnStop("ses_e")
unsubscribe()
setScenarioOverride("ses_e", "direct")
diff --git a/packages/app/src/components/prompt-input/scenario-override.ts b/packages/app/src/components/prompt-input/scenario-override.ts
index f098cb3f..58eae0f4 100644
--- a/packages/app/src/components/prompt-input/scenario-override.ts
+++ b/packages/app/src/components/prompt-input/scenario-override.ts
@@ -1,6 +1,6 @@
// D1/D3: scenario-mode override, set by the send-adjacent scenario toggle (D1).
// It overrides the configured promptMode for a turn. Cleared on stop (D3) so the override is
-// strictly per-turn: once a turn ends, resolution falls back to the configured default (e.g. wish)
+// strictly per-turn: once a turn ends, resolution falls back to the configured default (e.g. intelligence)
// rather than being pinned to a stale value. The next turn re-enters the configured scenario unless
// the user toggles again.
//
@@ -11,7 +11,7 @@
//
// This lives in its own module (no SolidJS imports) so submit.ts and the toggle UI can share it
// and it stays unit-testable without loading the router/runtime.
-export type ScenarioOverride = "direct" | "wish"
+export type ScenarioOverride = "direct" | "intelligence"
const scenarioOverride = new Map()
const listeners = new Set<() => void>()
@@ -39,7 +39,7 @@ export const resolveScenarioOverride = (sessionKey: string, dirKey: string): Sce
// D3: any stop CLEARS the per-turn override for both the session and its directory scope. Clearing
// (not pinning "direct") is deliberate: a pinned "direct" would permanently shadow the configured
// default and, because submit resolves the session key before the dir key, the toggle (which only
-// writes the dir key) could never re-engage wish. After clearing, the next turn falls back to the
+// writes the dir key) could never re-engage intelligence. After clearing, the next turn falls back to the
// configured scenario default.
export const resetScenarioOnStop = (sessionKey: string, dirKey?: string): void => {
scenarioOverride.delete(sessionKey)
diff --git a/packages/app/src/components/prompt-input/scenario-toggle.tsx b/packages/app/src/components/prompt-input/scenario-toggle.tsx
index ca942e0b..b7d25e2c 100644
--- a/packages/app/src/components/prompt-input/scenario-toggle.tsx
+++ b/packages/app/src/components/prompt-input/scenario-toggle.tsx
@@ -16,15 +16,15 @@ const scenarios = [
tooltip: "prompt.scenario.direct.tooltip" as const,
},
{
- mode: "wish" as const,
+ mode: "intelligence" as const,
icon: "speech-bubble" as const,
- label: "prompt.scenario.wish" as const,
- tooltip: "prompt.scenario.wish.tooltip" as const,
+ label: "prompt.scenario.intelligence" as const,
+ tooltip: "prompt.scenario.intelligence.tooltip" as const,
},
]
// D1: the per-turn scenario-mode toggle that sits to the left of the send button. It flips the
-// scenario between `direct` (the user owns the prompt) and `wish` (DeepAgent prepares the prompt
+// scenario between `direct` (the user owns the prompt) and `intelligence` (DeepAgent prepares the prompt
// and proposes next-round suggestions). It writes a DIRECTORY-scoped override (stable before a
// session exists) that submit.ts resolves session-then-directory, so a toggle made on the
// new-session composer still applies to the first turn. It defaults to the configured promptMode
@@ -39,12 +39,12 @@ export function ScenarioToggle() {
onCleanup(subscribeScenarioOverride(() => setVersion((value) => value + 1)))
// Effective scenario = directory override if set, else the configured default.
- const scenario = createMemo<"direct" | "wish">(() => {
+ const scenario = createMemo<"direct" | "intelligence">(() => {
version()
return getScenarioOverride(dirKey()) ?? deepAgentPromptModeFromConfig(serverSync.data.config)
})
- const select = (mode: "direct" | "wish") => setScenarioOverride(dirKey(), mode)
+ const select = (mode: "direct" | "intelligence") => setScenarioOverride(dirKey(), mode)
return (
{
})
if (text === "prepare fails") {
throw new Error("POST /session/ses_1/prompt_prepare returned 400", {
- cause: { body: { name: "BadRequest", data: { message: "Wish prompt preparation failed" } }, status: 400 },
+ cause: {
+ body: { name: "BadRequest", data: { message: "Intelligence prompt preparation failed" } },
+ status: 400,
+ },
})
}
return {
@@ -91,7 +96,7 @@ const clientFor = (directory: string) => {
prompt_draft_id: "prompt_draft:test:1",
context_plan_id: "context_plan:test:1",
state: "draft_ready",
- mode: payload.body?.mode ?? "wish",
+ mode: payload.body?.mode ?? "intelligence",
route: text === "hello" ? "general" : "code",
goal: "Prepared goal",
preview: "# Prepared prompt",
@@ -415,9 +420,9 @@ describe("prompt submit worktree selection", () => {
expect(optimisticSeeded).toEqual([true])
})
- test("prepares and confirms wish prompts before async submission", async () => {
+ test("prepares and confirms intelligence prompts before async submission", async () => {
params = { id: "session-1" }
- promptMode = "wish"
+ promptMode = "intelligence"
const submit = createPromptSubmit({
info: () => ({ id: "session-1" }),
@@ -446,13 +451,13 @@ describe("prompt submit worktree selection", () => {
expect(promptPrepareEvents).toEqual(["start", "end"])
expect(preparedDrafts).toEqual([
- { directory: "/repo/main", sessionID: "session-1", mode: "wish", outputLanguage: "english", text: "ls" },
+ { directory: "/repo/main", sessionID: "session-1", mode: "intelligence", outputLanguage: "english", text: "ls" },
])
expect(sentPromptAsync[0]?.text).toBe("Edited prepared goal")
expect(sentPromptAsync[0]?.metadata).toEqual({
deepagent: {
prompt_pipeline: {
- mode: "wish",
+ mode: "intelligence",
confirmed_draft_id: "prompt_draft:test:1",
edited_goal: "Edited prepared goal",
},
@@ -460,7 +465,9 @@ describe("prompt submit worktree selection", () => {
})
})
- test("prepares wish prompts in Chinese when the app locale is Chinese", async () => {
+ // Legacy-compat: a config still storing the pre-rename "wish" value must normalize to
+ // "intelligence" on the wire (read-old/send-new). Also exercises the Chinese output language.
+ test("prepares intelligence prompts in Chinese, normalizing a legacy 'wish' config to 'intelligence'", async () => {
params = { id: "session-1" }
promptMode = "wish"
appLocale = "zh"
@@ -487,13 +494,13 @@ describe("prompt submit worktree selection", () => {
await flushAsyncSubmit()
expect(preparedDrafts).toEqual([
- { directory: "/repo/main", sessionID: "session-1", mode: "wish", outputLanguage: "chinese", text: "ls" },
+ { directory: "/repo/main", sessionID: "session-1", mode: "intelligence", outputLanguage: "chinese", text: "ls" },
])
})
- test("routes non-code wish prompts through general without confirmation", async () => {
+ test("routes non-code intelligence prompts through general without confirmation", async () => {
params = { id: "session-1" }
- promptMode = "wish"
+ promptMode = "intelligence"
const confirms: string[] = []
promptValue[0] = { type: "text", content: "hello", start: 0, end: 5 }
@@ -525,7 +532,7 @@ describe("prompt submit worktree selection", () => {
expect(confirms).toEqual([])
expect(preparedDrafts).toEqual([
- { directory: "/repo/main", sessionID: "session-1", mode: "wish", outputLanguage: "english", text: "hello" },
+ { directory: "/repo/main", sessionID: "session-1", mode: "intelligence", outputLanguage: "english", text: "hello" },
])
expect(sentPromptAsync[0]?.text).toBe("hello")
expect(sentPromptAsync[0]?.metadata).toEqual({
@@ -539,9 +546,9 @@ describe("prompt submit worktree selection", () => {
promptValue[0] = { type: "text", content: "ls", start: 0, end: 2 }
})
- test("does not submit when wish prompt preparation fails", async () => {
+ test("does not submit when intelligence prompt preparation fails", async () => {
params = { id: "session-1" }
- promptMode = "wish"
+ promptMode = "intelligence"
promptValue[0] = { type: "text", content: "prepare fails", start: 0, end: 13 }
const submit = createPromptSubmit({
@@ -570,7 +577,7 @@ describe("prompt submit worktree selection", () => {
{
directory: "/repo/main",
sessionID: "session-1",
- mode: "wish",
+ mode: "intelligence",
outputLanguage: "english",
text: "prepare fails",
},
diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts
index 24f350d3..baa67555 100644
--- a/packages/app/src/components/prompt-input/submit.ts
+++ b/packages/app/src/components/prompt-input/submit.ts
@@ -40,7 +40,7 @@ export type FollowupDraft = {
metadata?: {
deepagent?: {
prompt_pipeline?: {
- mode: "wish" | "direct_override"
+ mode: "intelligence" | "direct_override"
confirmed_draft_id?: string
edited_goal?: string
}
@@ -53,7 +53,7 @@ export type DeepAgentPromptPrepareResult = {
prompt_draft_id: string
context_plan_id: string
state: string
- mode: "wish"
+ mode: "intelligence"
route: "code" | "general"
goal: string
preview: string
@@ -102,7 +102,7 @@ const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttac
const promptPipelineMode = (metadata: FollowupDraft["metadata"]): DeepAgentPromptModeForConfirmation | undefined => {
const mode = metadata?.deepagent?.prompt_pipeline?.mode
- return mode === "wish" ? mode : undefined
+ return mode === "intelligence" ? mode : undefined
}
async function prepareDeepAgentPromptDraft(input: {
@@ -227,7 +227,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
waited = true
if (!ok) return false
- // D2: while wish prepares the prompt (a real model call), surface a busy/"advice generating"
+ // D2: while intelligence prepares the prompt (a real model call), surface a busy/"advice generating"
// state so the composer indicates work is in progress rather than appearing frozen. The
// existing confirm surface then shows the prepared prompt for review/edit before send.
setBusy()
@@ -397,13 +397,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
// D3: any stop resets the scenario to `direct` and pauses scenario automation for this
// session and its directory scope, so the next user message is a plain direct submission
- // until they re-engage wish.
+ // until they re-engage intelligence.
resetScenarioOnStop(pendingKey(sessionID), ScopedKey.from(sdk.scope, sdk.directory))
- serverSync.todo.set(sessionID, [])
- const [, setStore] = serverSync.child(sdk.directory)
- setStore("todo", sessionID, [])
-
input.onAbort?.()
const key = pendingKey(sessionID)
@@ -585,11 +581,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
// selection swaps `sessionDirectory` to the new worktree dir). Read the same project-scoped
// key so the toggle's override is found at submit time even in the worktree case; resolve
// session-then-directory. After a stop (D3) the override is "direct", so the next turn is a
- // plain direct submission until the user re-engages wish.
+ // plain direct submission until the user re-engages intelligence.
const promptMode =
resolveScenarioOverride(pendingKey(session.id), ScopedKey.from(sdk.scope, projectDirectory)) ??
deepAgentPromptModeFromConfig(serverSync.data.config)
- if (promptMode === "wish") {
+ if (promptMode === "intelligence") {
draft.metadata = {
deepagent: {
prompt_pipeline: {
@@ -681,7 +677,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
const messageID = Identifier.ascending("message")
- const preparesPromptDraft = promptPipelineMode(draft.metadata) === "wish"
+ const preparesPromptDraft = promptPipelineMode(draft.metadata) === "intelligence"
const removeOptimisticMessage = () => {
sync.session.optimistic.remove({
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx
index ed1c71da..c3cbe55a 100644
--- a/packages/app/src/components/session/session-header.tsx
+++ b/packages/app/src/components/session/session-header.tsx
@@ -245,6 +245,13 @@ export function SessionHeader() {
const tint = createMemo(() =>
messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
)
+ // A running subagent (child session, parentID === current) puts a pulsing dot on the side-panel
+ // toggle so the user sees a spawn happened without opening the Subagents panel first.
+ const hasRunningSubagent = createMemo(() => {
+ const id = params.id
+ if (!id) return false
+ return sync.data.session.some((s) => s.parentID === id && sync.data.session_working(s.id))
+ })
const selectApp = (app: OpenApp) => {
if (!options().some((item) => item.id === app)) return
setPrefs("app", app)
@@ -463,13 +470,20 @@ export function SessionHeader() {
>
diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx
index e6ad5a48..2c076f06 100644
--- a/packages/app/src/components/settings-v2/general.tsx
+++ b/packages/app/src/components/settings-v2/general.tsx
@@ -35,11 +35,13 @@ import "./settings-v2.css"
import {
deepAgentModeFromConfig,
deepAgentPromptModeFromConfig,
- deepAgentWishModelFromConfig,
+ deepAgentIntelligenceModelFromConfig,
deepAgentSelfLearningFromConfig,
+ deepAgentSubagentIntensityFromConfig,
type DeepAgentMode,
type DeepAgentPromptMode,
type DeepAgentSelfLearning,
+ type DeepAgentSubagentIntensity,
updateDeepAgentOptions,
} from "@/utils/deepagent-settings"
import { useModels } from "@/context/models"
@@ -205,15 +207,15 @@ export const SettingsGeneralV2: Component = () => {
description: language.t("settings.general.deepagent.prompt.direct.description"),
},
{
- value: "wish",
- label: language.t("settings.general.deepagent.prompt.wish"),
- description: language.t("settings.general.deepagent.prompt.wish.description"),
+ value: "intelligence",
+ label: language.t("settings.general.deepagent.prompt.intelligence"),
+ description: language.t("settings.general.deepagent.prompt.intelligence.description"),
},
])
const scenarioModeDescription = createMemo(
() => scenarioModeCards().find((card) => card.value === deepAgentPromptMode())?.description ?? "",
)
- // #3: `ultra` requires the wish scenario; it cannot run under `direct`. Gate against the
+ // #3: `ultra` requires the intelligence scenario; it cannot run under `direct`. Gate against the
// config-level scenario mode (this selector is a session/config-level setting, not per-turn).
// When the scenario is `direct`, drop `ultra` from the selectable options (unless it is somehow
// already the current value, so the control still reflects state) and surface an explanatory
@@ -231,8 +233,15 @@ export const SettingsGeneralV2: Component = () => {
}
return options
})
- const deepAgentWishModel = createMemo(() => deepAgentWishModelFromConfig(serverSync.data.config) ?? "")
- const wishModelOptions = createMemo(() =>
+ const deepAgentSubagentIntensity = createMemo(() => deepAgentSubagentIntensityFromConfig(serverSync.data.config))
+ const subagentIntensityOptions = createMemo<{ value: DeepAgentSubagentIntensity; label: string }[]>(() => [
+ { value: "inherit", label: language.t("settings.general.deepagent.subagentMode.inherit") },
+ { value: "downgrade", label: language.t("settings.general.deepagent.subagentMode.downgrade") },
+ ])
+ const deepAgentIntelligenceModel = createMemo(
+ () => deepAgentIntelligenceModelFromConfig(serverSync.data.config) ?? "",
+ )
+ const intelligenceModelOptions = createMemo(() =>
models.list().map((model) => ({
value: `${model.provider.id}/${model.id}`,
label: model.name,
@@ -357,7 +366,9 @@ export const SettingsGeneralV2: Component = () => {
{language.t("settings.general.deepagent.mode.description")}
{" "}
- {language.t("settings.general.deepagent.mode.ultraRequiresWish")}
+
+ {language.t("settings.general.deepagent.mode.ultraRequiresIntelligence")}
+
>
}
@@ -380,6 +391,27 @@ export const SettingsGeneralV2: Component = () => {
/>
+
+ o.value === deepAgentSubagentIntensity())}
+ value={(o) => o.value}
+ label={(o) => o.label}
+ onSelect={(option) => {
+ if (!option) return
+ if (option.value === deepAgentSubagentIntensity()) return
+ void updateDeepAgentOptions(serverSync, { subagentIntensity: option.value })
+ }}
+ />
+
+
{
option.value === deepAgentWishModel()) ?? wishModelOptions()[0]
+ intelligenceModelOptions().find((option) => option.value === deepAgentIntelligenceModel()) ??
+ intelligenceModelOptions()[0]
}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
- if (option.value === deepAgentWishModel()) return
- void updateDeepAgentOptions(serverSync, { wishModel: option.value })
+ if (option.value === deepAgentIntelligenceModel()) return
+ void updateDeepAgentOptions(serverSync, { intelligenceModel: option.value })
}}
>
{(o) => (
diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts
index c2957286..7663c2cf 100644
--- a/packages/app/src/context/directory-sync.ts
+++ b/packages/app/src/context/directory-sync.ts
@@ -191,7 +191,6 @@ export const createDirSyncContext = (
const historyMessagePageSize = 200
const inflight = new Map>()
const inflightDiff = new Map>()
- const inflightTodo = new Map>()
const optimistic = new Map>()
const maxDirs = 30
const seen = new Map>()
@@ -277,9 +276,6 @@ export const createDirSyncContext = (
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
clearSessionPrefetch(serverSDK.scope, directory, sessionIDs)
- for (const sessionID of sessionIDs) {
- serverSync.todo.set(sessionID, undefined)
- }
setStore(
produce((draft) => {
dropSessionCaches(draft, sessionIDs)
@@ -522,32 +518,6 @@ export const createDirSyncContext = (
}),
)
},
- async todo(sessionID: string, opts?: { force?: boolean }) {
- const [store, setStore] = serverSync.child(directory)
- touch(directory, setStore, sessionID)
- const existing = store.todo[sessionID]
- const cached = serverSync.data.session_todo[sessionID]
- if (existing !== undefined) {
- if (cached === undefined) {
- serverSync.todo.set(sessionID, existing)
- }
- if (!opts?.force) return
- }
-
- if (cached !== undefined) {
- setStore("todo", sessionID, reconcile(cached, { key: "id" }))
- }
-
- const key = keyFor(directory, sessionID)
- return runInflight(inflightTodo, key, () =>
- retry(() => client.session.todo({ sessionID })).then((todo) => {
- if (!tracked(directory, sessionID)) return
- const list = todo.data ?? []
- setStore("todo", sessionID, reconcile(list, { key: "id" }))
- serverSync.todo.set(sessionID, list)
- }),
- )
- },
history: {
more(sessionID: string) {
const store = current()[0]
diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts
index 343f2880..fe66bb35 100644
--- a/packages/app/src/context/global-sync/bootstrap.ts
+++ b/packages/app/src/context/global-sync/bootstrap.ts
@@ -7,7 +7,6 @@ import type {
ProviderAuthResponse,
QuestionRequest,
Session,
- Todo,
} from "@deepagent-code/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { getFilename } from "@deepagent-code/core/util/path"
@@ -26,9 +25,6 @@ type GlobalStore = {
ready: boolean
path: Path
project: Project[]
- session_todo: {
- [sessionID: string]: Todo[]
- }
session_plan: {
[sessionID: string]: import("./types").SessionPlan
}
diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts
index 05cdd503..29202b08 100644
--- a/packages/app/src/context/global-sync/event-reducer.test.ts
+++ b/packages/app/src/context/global-sync/event-reducer.test.ts
@@ -1,7 +1,9 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@deepagent-code/sdk/v2/client"
-import { createStore } from "solid-js/store"
-import type { State } from "./types"
+import { createRoot } from "solid-js"
+import { isServer } from "solid-js/web"
+import { createStore, reconcile, unwrap } from "solid-js/store"
+import type { SessionPlan, State } from "./types"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
@@ -274,7 +276,6 @@ describe("applyDirectoryEvent", () => {
const dropped = rootSession({ id: "ses_b" })
const kept = rootSession({ id: "ses_a" })
const message = userMessage("msg_1", dropped.id)
- const todos: string[] = []
const [store, setStore] = createStore(
baseState({
limit: 1,
@@ -296,10 +297,6 @@ describe("applyDirectoryEvent", () => {
push() {},
directory: "/tmp",
loadLsp() {},
- setSessionTodo(sessionID, value) {
- if (value !== undefined) return
- todos.push(sessionID)
- },
})
expect(store.session.map((x) => x.id)).toEqual([kept.id])
@@ -310,7 +307,6 @@ describe("applyDirectoryEvent", () => {
expect(store.permission[dropped.id]).toBeUndefined()
expect(store.question[dropped.id]).toBeUndefined()
expect(store.session_status[dropped.id]).toBeUndefined()
- expect(todos).toEqual([dropped.id])
})
test("cleanupDroppedSessionCaches clears part-only orphan state", () => {
@@ -574,3 +570,125 @@ describe("applyDirectoryEvent", () => {
expect(lspLoads).toBe(1)
})
})
+
+// Regression guard for the plan panel. The model pushes repeated plan.updated events that advance
+// step statuses; the reducer feeds them into a per-session plan store via reconcile. The previous
+// code used `{ key: "plan_id" }`, which reconcile applies recursively to the nested `steps[]` array
+// — but a step's identity field is `step_id`, not `plan_id`, so every step resolved to
+// `key=undefined`.
+//
+// IMPORTANT (verified against solid-js@1.9.10): the wrong key does NOT drop status updates — field
+// values land correctly under plan_id, step_id, or null alike (see the status-advance test below,
+// which passes under all three keys). The only behavioural difference is per-step PROXY IDENTITY on
+// reorder, and even that is invisible to the current UI: the dock renders via (positional)
+// over plain objects the `planAsTodos` memo re-creates every tick, so store-proxy identity is never
+// consumed by the render. `key: "step_id"` is therefore the correct minimal-diff choice on data-
+// contract grounds (field-level updates instead of whole-object replace, correct identity if the
+// render ever moves to a keyed ), not a fix for a reproducible "stuck" symptom at this layer.
+// The status-advance test IS the meaningful CI regression guard; the identity test below only
+// documents the proxy-identity contract and requires --conditions=browser (see its comment).
+describe("plan.updated reconcile (session_plan)", () => {
+ const planEvent = (
+ sessionID: string,
+ steps: Array<[step_id: string, status: string]>,
+ activeStepID: string | null,
+ ) => ({
+ type: "plan.updated",
+ properties: {
+ sessionID,
+ plan_id: "plan_1",
+ goal: "ship it",
+ active_step_id: activeStepID,
+ steps: steps.map(([step_id, status]) => ({ step_id, title: step_id.toUpperCase(), status })),
+ done: steps.filter(([, status]) => status === "done").length,
+ total: steps.length,
+ },
+ })
+
+ // Mirror the real setSessionPlan writer in server-sync.tsx: session_plan[sid] = reconcile(plan, ...).
+ const makeSetSessionPlan = (
+ setPlanStore: (path: "sp", sid: string, value: unknown) => void,
+ key: string | null,
+ ) => {
+ return (sessionID: string, plan: SessionPlan | undefined) => {
+ if (!plan) return
+ setPlanStore("sp", sessionID, reconcile(plan, { key }) as never)
+ }
+ }
+
+ const dispatch = (setStore: any, store: any, setSessionPlan: any, event: any) => {
+ applyDirectoryEvent({
+ event,
+ store,
+ setStore,
+ push() {},
+ directory: "/tmp",
+ loadLsp() {},
+ setSessionPlan,
+ })
+ }
+
+ test("two consecutive plan.updated events advance step status through the reducer", () => {
+ createRoot((dispose) => {
+ const [store, setStore] = createStore(baseState())
+ const [planStore, setPlanStore] = createStore<{ sp: Record }>({ sp: {} })
+ const setSessionPlan = makeSetSessionPlan(setPlanStore as never, "step_id")
+
+ // First plan.updated: step "a" is active, the rest pending.
+ dispatch(setStore, store, setSessionPlan, planEvent("ses_1", [["a", "active"], ["b", "pending"], ["c", "pending"]], "a"))
+ expect(planStore.sp.ses_1.steps.map((s) => s.status)).toEqual(["active", "pending", "pending"])
+
+ // Second plan.updated: same plan_id, "a" done and "b" now active. This is the event the old
+ // code effectively dropped from the panel's point of view.
+ dispatch(setStore, store, setSessionPlan, planEvent("ses_1", [["a", "done"], ["b", "active"], ["c", "pending"]], "b"))
+ expect(planStore.sp.ses_1.steps.map((s) => s.status)).toEqual(["done", "active", "pending"])
+
+ // Third advance, to be thorough.
+ dispatch(setStore, store, setSessionPlan, planEvent("ses_1", [["a", "done"], ["b", "done"], ["c", "active"]], "c"))
+ expect(planStore.sp.ses_1.steps.map((s) => s.status)).toEqual(["done", "done", "active"])
+ expect(planStore.sp.ses_1.active_step_id).toBe("c")
+ expect(planStore.sp.ses_1.done).toBe(2)
+
+ dispose()
+ })
+ })
+
+ // Per-step proxy identity across reorder only manifests under the CLIENT (browser) build of
+ // Solid's store; the SSR build never retains proxy identity, so this assertion is only meaningful
+ // under `--conditions=browser`. CI GAP: the package `test`/`test:ci` scripts run SSR-only, so this
+ // test is skipped in CI today. It is NOT a user-visible regression guard — the dock renders steps
+ // positionally via over memo-recreated plain objects, so proxy identity never reaches the
+ // render. It documents the reconcile identity contract for a future keyed- render only. To
+ // exercise it locally: `bun test --conditions=browser --preload ./happydom.ts `.
+ test.skipIf(isServer)("keying by step_id preserves per-step identity across reorder", () => {
+ const mk = (rows: Array<[string, string]>): SessionPlan => ({
+ plan_id: "plan_1",
+ goal: "g",
+ active_step_id: rows[0]?.[0] ?? null,
+ steps: rows.map(([step_id, status]) => ({ step_id, title: step_id.toUpperCase(), status })),
+ done: rows.filter(([, s]) => s === "done").length,
+ total: rows.length,
+ })
+
+ const identityStable = (key: string) =>
+ createRoot((dispose) => {
+ const [store, setStore] = createStore<{ p?: SessionPlan }>({})
+ setStore("p", reconcile(mk([["a", "active"], ["b", "pending"]]), { key }))
+ const before = unwrap(store.p!.steps.find((s) => s.step_id === "a")!)
+ setStore("p", reconcile(mk([["b", "active"], ["a", "done"]]), { key }))
+ const after = unwrap(store.p!.steps.find((s) => s.step_id === "a")!)
+ dispose()
+ return before === after
+ })
+
+ // The fix keys by step_id and keeps a step's identity stable when the array reorders.
+ expect(identityStable("step_id")).toBe(true)
+ // The old (buggy) key does not.
+ expect(identityStable("plan_id")).toBe(false)
+ })
+})
+
+// NOTE: the `todo.updated reconcile` describe block was removed here. Task tracking is unified onto
+// the plan system: the backend no longer emits `todo.updated` (both todowrite tool writers were
+// removed) and the reducer no longer handles it. The plan panel's live-update coverage lives in the
+// `plan.updated reconcile (session_plan)` describe block below.
diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts
index f57782dd..b2cd5cb2 100644
--- a/packages/app/src/context/global-sync/event-reducer.ts
+++ b/packages/app/src/context/global-sync/event-reducer.ts
@@ -9,7 +9,6 @@ import type {
Session,
SessionStatus,
SnapshotFileDiff,
- Todo,
} from "@deepagent-code/sdk/v2/client"
import type { State, VcsCache, SessionPlan } from "./types"
import { trimSessions } from "./session-trim"
@@ -47,13 +46,8 @@ export function applyGlobalEvent(input: {
)
}
-function cleanupSessionCaches(
- setStore: SetStoreFunction,
- sessionID: string,
- setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
-) {
+function cleanupSessionCaches(setStore: SetStoreFunction, sessionID: string) {
if (!sessionID) return
- setSessionTodo?.(sessionID, undefined)
setStore(
produce((draft) => {
dropSessionCaches(draft, [sessionID])
@@ -61,12 +55,7 @@ function cleanupSessionCaches(
)
}
-export function cleanupDroppedSessionCaches(
- store: Store,
- setStore: SetStoreFunction,
- next: Session[],
- setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
-) {
+export function cleanupDroppedSessionCaches(store: Store, setStore: SetStoreFunction, next: Session[]) {
const keep = new Set(next.map((item) => item.id))
const stale = [
...Object.keys(store.message),
@@ -80,9 +69,6 @@ export function cleanupDroppedSessionCaches(
.filter((sessionID): sessionID is string => !!sessionID),
].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index)
if (stale.length === 0) return
- for (const sessionID of stale) {
- setSessionTodo?.(sessionID, undefined)
- }
setStore(
produce((draft) => {
dropSessionCaches(draft, stale)
@@ -98,7 +84,6 @@ export function applyDirectoryEvent(input: {
directory: string
loadLsp: () => void
vcsCache?: VcsCache
- setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
setSessionPlan?: (sessionID: string, plan: SessionPlan | undefined) => void
retainedLimit?: number
}) {
@@ -120,7 +105,7 @@ export function applyDirectoryEvent(input: {
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit, permission: input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
- cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
+ cleanupDroppedSessionCaches(input.store, input.setStore, trimmed)
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
break
}
@@ -137,7 +122,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
- cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
+ cleanupSessionCaches(input.setStore, info.id)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@@ -150,7 +135,7 @@ export function applyDirectoryEvent(input: {
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit, permission: input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
- cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
+ cleanupDroppedSessionCaches(input.store, input.setStore, trimmed)
break
}
case "session.deleted": {
@@ -164,7 +149,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
- cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
+ cleanupSessionCaches(input.setStore, info.id)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@@ -174,16 +159,12 @@ export function applyDirectoryEvent(input: {
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
break
}
- case "todo.updated": {
- const props = event.properties as { sessionID: string; todos: Todo[] }
- input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
- input.setSessionTodo?.(props.sessionID, props.todos)
- break
- }
+ // NOTE: the `todo.updated` event handler was removed when task tracking unified onto the plan
+ // system. The backend no longer emits `todo.updated` (both todowrite tool writers were removed),
+ // and the dock renders the plan exclusively. See `plan.updated` below for the live task source.
case "plan.updated": {
- // U2: the live plan (goal + steps + progress) from the `plan` tool. Stored under a distinct
- // session_plan key so it persists when the session goes idle (unlike the todo cache, which the
- // composer clears on idle).
+ // The live plan (goal + steps + progress) from the `plan` tool. Stored under a distinct
+ // session_plan key so it persists when the session goes idle.
const props = event.properties as {
sessionID: string
plan_id: string
diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx
index 74ebb090..83133d3c 100644
--- a/packages/app/src/context/server-sync.tsx
+++ b/packages/app/src/context/server-sync.tsx
@@ -6,7 +6,6 @@ import type {
Path,
Project,
ProviderAuthResponse,
- Todo,
} from "@deepagent-code/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { getFilename } from "@deepagent-code/core/util/path"
@@ -55,11 +54,9 @@ type GlobalStore = {
error?: InitError
path: Path
project: Project[]
- session_todo: {
- [sessionID: string]: Todo[]
- }
- // U2: the live plan per session (goal + steps + progress) pushed by the `plan` tool's
- // plan.updated event. Persistent — survives a session going idle (unlike the todo cache).
+ // The live plan per session (goal + steps + progress) pushed by the `plan` tool's plan.updated
+ // event. Persistent — survives a session going idle. This is the SINGLE source for the task dock;
+ // the legacy `session_todo` cache was removed when task tracking unified onto the plan system.
session_plan: {
[sessionID: string]: SessionPlan
}
@@ -135,7 +132,6 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
return !bootstrap.isPending
},
project: [],
- session_todo: {},
session_plan: {},
provider_auth: {},
get path() {
@@ -238,21 +234,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
}) as typeof setGlobalStore
- const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
- if (!sessionID) return
- if (!todos) {
- setGlobalStore(
- "session_todo",
- produce((draft) => {
- delete draft[sessionID]
- }),
- )
- return
- }
- setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" }))
- }
-
- // U2: set/clear the live plan for a session.
+ // Set/clear the live plan for a session.
const setSessionPlan = (sessionID: string, plan: SessionPlan | undefined) => {
if (!sessionID) return
if (!plan) {
@@ -264,7 +246,16 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
)
return
}
- setGlobalStore("session_plan", sessionID, reconcile(plan, { key: "plan_id" }))
+ // The outer object is a single plan per session (not an array), so a key is meaningless at this
+ // level — but reconcile applies `key` RECURSIVELY to the nested `steps[]` array, where the
+ // identity field is `step_id`, not `plan_id`. With the wrong key every step resolves to
+ // `key=undefined`, so reconcile can't match steps across updates and falls back to replacing
+ // whole step objects by position (extra store-cell churn). NOTE: this does NOT drop status
+ // changes — field values still land either way (verified), and the dock renders steps via
+ // over plain objects re-created by the `planAsTodos` memo, so proxy identity is never
+ // consumed by the render. Keying by `step_id` is the correct, minimal-diff choice (field-level
+ // updates, stable identity) and is future-proof if the render ever switches to a keyed .
+ setGlobalStore("session_plan", sessionID, reconcile(plan, { key: "step_id" }))
}
const paused = () => untrack(() => globalStore.reload) !== undefined
@@ -332,7 +323,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
})
if (next.length !== store.session.length) {
setStore("session", reconcile(next, { key: "id" }))
- cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
+ cleanupDroppedSessionCaches(store, setStore, next)
}
children.unpin(key)
return
@@ -369,7 +360,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
}),
)
setStore("session", reconcile(sessions, { key: "id" }))
- cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
+ cleanupDroppedSessionCaches(store, setStore, sessions)
})
sessionMeta.set(key, { limit })
})
@@ -469,7 +460,6 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
store,
setStore,
push: queue.push,
- setSessionTodo,
setSessionPlan,
retainedLimit: sessionMeta.get(key)?.limit,
vcsCache: children.vcsCache.get(key),
@@ -618,9 +608,6 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
updateConfig: updateConfigMutation.mutateAsync,
refreshProviders,
project: projectApi,
- todo: {
- set: setSessionTodo,
- },
plan: {
set: setSessionPlan,
},
diff --git a/packages/app/src/hooks/use-im-websocket.ts b/packages/app/src/hooks/use-im-websocket.ts
index 55aca6eb..0fc15337 100644
--- a/packages/app/src/hooks/use-im-websocket.ts
+++ b/packages/app/src/hooks/use-im-websocket.ts
@@ -2,11 +2,25 @@ import { createSignal, createEffect, onCleanup } from "solid-js"
import type { IMMessage } from "@/components/im/types"
import type { IMClient } from "@/utils/im-client"
+// One live snapshot of a part of an in-flight agent turn (reasoning / assistant
+// text / tool activity). The client keeps a map keyed by `partID` and REPLACES
+// each entry as batches arrive, so a dropped/reordered batch self-heals on the
+// next snapshot. `order` gives stable render order.
+export interface AgentProgressPart {
+ partID: string
+ order: number
+ kind: "reasoning" | "text" | "tool"
+ text?: string
+ tool?: string
+ status?: string
+}
+
// WebSocket event types
export type IMWebSocketEvent =
| { type: "message_created"; data: IMMessage }
| { type: "message_failed"; data: { clientMessageID?: string; code: string; message: string; retryable: boolean } }
| { type: "agent_status"; data: { messageID: string; agentID: string; status: string; error?: any } }
+ | { type: "agent_progress"; data: { messageID: string; agentID: string; parts: AgentProgressPart[] } }
| { type: "typing"; data: { groupID: string; memberID: string; typing: boolean } }
| { type: "read_receipt"; data: { groupID: string; memberID: string; readAt: number } }
| { type: "ping"; data: { ts: number } }
@@ -31,6 +45,9 @@ export function useIMWebSocket(client: IMClient, groupID: () => string | null) {
const [reconnectAttempt, setReconnectAttempt] = createSignal(0)
const [messages, setMessages] = createSignal([])
const [agentStatuses, setAgentStatuses] = createSignal
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 8082aa10..c596397b 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -755,9 +755,6 @@ export default function Layout(props: ParentProps) {
if (stale.length > 0) {
clearSessionPrefetch(serverSDK.scope, directory, stale)
- for (const id of stale) {
- serverSync.todo.set(id, undefined)
- }
}
const current = store.message[sessionID] ?? []
diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts
index ae452c42..29644eb0 100644
--- a/packages/app/src/pages/layout/helpers.test.ts
+++ b/packages/app/src/pages/layout/helpers.test.ts
@@ -10,6 +10,7 @@ import { type Session } from "@deepagent-code/sdk/v2/client"
import {
childSessionOnPath,
closeHomeProject,
+ directChildSessions,
displayName,
effectiveWorkspaceOrder,
errorMessage,
@@ -18,6 +19,8 @@ import {
homeProjectDirectories,
homeSessionServerStatus,
latestRootSession,
+ roots,
+ sessionOriginID,
toggleHomeProjectSelection,
} from "./helpers"
import { pathKey } from "@/utils/path-key"
@@ -222,6 +225,52 @@ describe("layout workspace helpers", () => {
expect(childSessionOnPath(list, "root", "other")).toBeUndefined()
})
+ test("resolves session origin from subagent parentID or fork metadata", () => {
+ expect(sessionOriginID(session({ id: "a", directory: "/w" }))).toBeUndefined()
+ expect(sessionOriginID(session({ id: "b", directory: "/w", parentID: "root" }))).toBe("root")
+ expect(
+ sessionOriginID(session({ id: "c", directory: "/w", metadata: { forkedFrom: { parentSessionID: "root" } } })),
+ ).toBe("root")
+ // parentID (subagent) wins if both are somehow present.
+ expect(
+ sessionOriginID(
+ session({ id: "d", directory: "/w", parentID: "p", metadata: { forkedFrom: { parentSessionID: "f" } } }),
+ ),
+ ).toBe("p")
+ })
+
+ test("roots exclude both subagents and forks so a fork is never double-listed", () => {
+ const store = {
+ path: { directory: "/w" },
+ session: [
+ session({ id: "root", directory: "/w" }),
+ session({ id: "sub", directory: "/w", parentID: "root" }),
+ session({ id: "fork", directory: "/w", metadata: { forkedFrom: { parentSessionID: "root" } } }),
+ ],
+ }
+ expect(roots(store).map((s) => s.id)).toEqual(["root"])
+ })
+
+ test("directChildSessions groups subagents and forks under their origin, newest first", () => {
+ const list = [
+ session({ id: "root", directory: "/w" }),
+ session({ id: "sub", directory: "/w", parentID: "root", time: { created: 1, updated: 10, archived: undefined } }),
+ session({
+ id: "fork",
+ directory: "/w",
+ metadata: { forkedFrom: { parentSessionID: "root" } },
+ time: { created: 2, updated: 20, archived: undefined },
+ }),
+ session({
+ id: "archived",
+ directory: "/w",
+ parentID: "root",
+ time: { created: 3, updated: 30, archived: 99 },
+ }),
+ ]
+ expect(directChildSessions(list, "root").map((s) => s.id)).toEqual(["fork", "sub"])
+ })
+
test("formats fallback project display name", () => {
expect(displayName({ worktree: "/tmp/app" })).toBe("app")
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts
index 17a6881c..49fa46a7 100644
--- a/packages/app/src/pages/layout/helpers.ts
+++ b/packages/app/src/pages/layout/helpers.ts
@@ -22,8 +22,12 @@ function sortSessions(now: number) {
}
}
+// A root row has no origin link of EITHER kind — neither a subagent `parentID` nor a fork
+// `metadata.forkedFrom`. Excluding fork origins here is what stops a fork from appearing both as a
+// top-level row AND nested under its parent (forks carry no parentID, so the old `!parentID` check
+// alone would double-list them).
const isRootVisibleSession = (session: Session, directory: string) =>
- pathKey(session.directory) === pathKey(directory) && !session.parentID && !session.time?.archived
+ pathKey(session.directory) === pathKey(directory) && !sessionOriginID(session) && !session.time?.archived
export const roots = (store: SessionStore) =>
(store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory))
@@ -53,6 +57,29 @@ export const childSessionOnPath = (sessions: Session[] | undefined, rootID: stri
}
}
+// The id of the session a child hangs off in the sidebar tree. Two lineage kinds are unified:
+// • subagents — `parentID` (background workers spawned by the task tool)
+// • forks — `metadata.forkedFrom.parentSessionID` (foreground "derived from" sessions; forks
+// deliberately do NOT set parentID, which would give them subagent semantics)
+// Roots (`roots()` above) are sessions with neither link, so a fork/subagent never also shows as a
+// top-level row.
+export const sessionOriginID = (session: Session): string | undefined => {
+ if (session.parentID) return session.parentID
+ const forkedFrom = (session.metadata as { forkedFrom?: { parentSessionID?: string } } | undefined)?.forkedFrom
+ return forkedFrom?.parentSessionID
+}
+
+// Direct children (subagents + forks) of a session, newest first. Used to nest sessions folder-style
+// under their origin in the sidebar.
+export const directChildSessions = (sessions: Session[] | undefined, originID: string): Session[] =>
+ (sessions ?? [])
+ .filter((s) => !s.time?.archived && sessionOriginID(s) === originID)
+ .sort((a, b) => (b.time?.updated ?? b.time?.created ?? 0) - (a.time?.updated ?? a.time?.created ?? 0))
+
+// Max nesting depth mirrored from the backend fork cap (root → fork → fork-of-fork = 3 levels, i.e.
+// level indices 0..2). Deeper descendants stop nesting so a corrupted chain can't recurse forever.
+export const MAX_SESSION_TREE_LEVEL = 2
+
export const displayName = (project: { name?: string; worktree: string }) =>
project.name || getFilename(project.worktree) || project.worktree
diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx
index f2d42937..eb10171c 100644
--- a/packages/app/src/pages/layout/sidebar-items.tsx
+++ b/packages/app/src/pages/layout/sidebar-items.tsx
@@ -15,7 +15,12 @@ import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
-import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
+import {
+ directChildSessions,
+ getProjectAvatarSource,
+ hasProjectPermissions,
+ MAX_SESSION_TREE_LEVEL,
+} from "./helpers"
export const ProjectIcon = (props: {
project: LocalProject
@@ -162,9 +167,14 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
const tint = createMemo(() => messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent))
const tooltip = createMemo(() => props.showTooltip ?? (props.mobile || !props.sidebarExpanded()))
- const currentChild = createMemo(() => {
- if (!props.showChild) return
- return childSessionOnPath(sessionStore.session, props.session.id, params.id)
+ const level = createMemo(() => props.level ?? 0)
+ // Folder-style nesting: show ALL direct children (subagents + forks) under this row, capped at the
+ // same depth as the backend fork limit. Stops descending past the cap so a corrupted lineage chain
+ // can't recurse without bound.
+ const childSessions = createMemo(() => {
+ if (!props.showChild) return []
+ if (level() >= MAX_SESSION_TREE_LEVEL) return []
+ return directChildSessions(sessionStore.session, props.session.id)
})
const warm = (span: number, priority: "high" | "low") => {
@@ -258,12 +268,12 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
-
- {(child) => (
-
-
-
- )}
+ 0}>
+
+
+ {(child) => }
+
+
>
)
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index c66d8169..415469d0 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -29,7 +29,7 @@ import { previewSelectedLines } from "@deepagent-code/ui/pierre/selection-bridge
import { Button } from "@deepagent-code/ui/button"
import { showToast } from "@/utils/toast"
import { checksum } from "@deepagent-code/core/util/encode"
-import { useLocation, useSearchParams } from "@solidjs/router"
+import { useLocation, useNavigate, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
@@ -52,6 +52,7 @@ import {
} from "@/components/prompt-input/submit"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
import {
+ createForkAction,
createOpenReviewFile,
createSessionTabs,
createSizing,
@@ -203,6 +204,7 @@ export default function Page() {
const comments = useComments()
const terminal = useTerminal()
const server = useServer()
+ const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation()
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
@@ -446,8 +448,6 @@ export default function Page() {
let reviewFrame: number | undefined
let refreshFrame: number | undefined
let refreshTimer: number | undefined
- let todoFrame: number | undefined
- let todoTimer: number | undefined
let diffFrame: number | undefined
let diffTimer: number | undefined
@@ -697,40 +697,9 @@ export default function Page() {
},
)
- createEffect(
- on(
- () => {
- const id = params.id
- return [
- sdk.directory,
- id,
- id ? (sync.data.session_status[id]?.type ?? "idle") : "idle",
- id ? composer.blocked() : false,
- ] as const
- },
- ([dir, id, status, blocked]) => {
- if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
- if (todoTimer !== undefined) window.clearTimeout(todoTimer)
- todoFrame = undefined
- todoTimer = undefined
- if (!id) return
- if (status === "idle" && !blocked) return
- const cached = untrack(() => sync.data.todo[id] !== undefined || serverSync.data.session_todo[id] !== undefined)
-
- todoFrame = requestAnimationFrame(() => {
- todoFrame = undefined
- todoTimer = window.setTimeout(() => {
- todoTimer = undefined
- if (sdk.directory !== dir || params.id !== id) return
- untrack(() => {
- void sync.session.todo(id, cached ? { force: true } : undefined)
- })
- }, 0)
- })
- },
- { defer: true },
- ),
- )
+ // NOTE: the per-turn todo prefetch effect was removed when task tracking unified onto the plan
+ // system. The task dock is now driven exclusively by the persistent plan (session_plan), which
+ // arrives via the plan.updated SSE stream — there is no REST todo endpoint to prime on activation.
createEffect(
on(
@@ -1652,7 +1621,31 @@ export default function Page() {
.map((item) => ({ id: item.id, text: line(item.id) }))
})
- const actions = { revert }
+ const fork = createForkAction({
+ open: (DialogFork) => dialog.show(() => ),
+ messages: (sessionID) => sync.data.message[sessionID] ?? [],
+ fork: async (input) => {
+ const forked = await sdk.client.session.fork(input)
+ if (!forked.data) {
+ showToast({ title: language.t("common.requestFailed") })
+ return
+ }
+ return forked.data
+ },
+ navigate: (sessionID) => {
+ local.session.promote(sdk.directory, sessionID)
+ layout.handoff.setTabs(local.slug(), sessionID)
+ navigate(`/${local.slug()}/session/${sessionID}`)
+ },
+ onError: (err) => {
+ showToast({
+ title: language.t("common.requestFailed"),
+ description: formatServerError(err),
+ })
+ },
+ })
+
+ const actions = { revert, fork }
createEffect(() => {
const sessionID = params.id
@@ -1729,8 +1722,6 @@ export default function Page() {
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
- if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
- if (todoTimer !== undefined) window.clearTimeout(todoTimer)
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts
index 3b3bdc3c..fe2f10c4 100644
--- a/packages/app/src/pages/session/composer/session-composer-state.ts
+++ b/packages/app/src/pages/session/composer/session-composer-state.ts
@@ -59,12 +59,14 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
})) as unknown as Todo[]
})
+ // Task tracking is unified onto the plan system — the dock renders the plan's steps (mapped to
+ // the Todo shape it already knows). The legacy per-turn `todowrite` track was removed because it
+ // shadowed the plan here (a plan with >0 steps unconditionally won), so todo-based progress
+ // reports were never visible. There is now a single source: the persistent plan.
const todos = createMemo((): Todo[] => {
const id = params.id
if (!id) return []
- const planTodos = planAsTodos()
- if (planTodos.length > 0) return planTodos
- return serverSync.data.session_todo[id] ?? []
+ return planAsTodos()
})
// True when this session is driven by a persistent plan — clear() must NOT wipe it on idle.
@@ -126,16 +128,11 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
}, closeMs())
}
- // Keep stale turn todos from reopening if the model never clears them.
- // U2 guard: a persistent plan must NOT be wiped on idle — only the transient per-turn todo cache
- // is cleared. The plan lives under session_plan and is left untouched here.
- const clear = () => {
- const id = params.id
- if (!id) return
- if (hasPlan()) return
- serverSync.todo.set(id, [])
- sync.set("todo", id, [])
- }
+ // The task dock is now driven exclusively by the persistent plan (session_plan), which must NOT
+ // be wiped on idle. Since the dock's todos come only from the plan, `todos().length > 0` implies
+ // `hasPlan()`, so the FSM's "clear" state (count > 0 && !live) is unreachable and this is a no-op
+ // guard kept for clarity — there is no transient per-turn todo cache left to clear.
+ const clear = () => {}
createEffect(
on(
diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts
index 4ef316ee..3ecab203 100644
--- a/packages/app/src/pages/session/helpers.test.ts
+++ b/packages/app/src/pages/session/helpers.test.ts
@@ -2,10 +2,12 @@ import { describe, expect, test } from "bun:test"
import { createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import {
+ createForkAction,
createOpenReviewFile,
createOpenSessionFileTab,
createSessionTabs,
focusTerminalById,
+ forkCutoffMessageID,
getTabReorderIndex,
shouldFocusTerminalOnKeyDown,
} from "./helpers"
@@ -30,6 +32,71 @@ describe("createOpenReviewFile", () => {
})
})
+describe("createForkAction", () => {
+ test("uses the next message as the cutoff so the clicked bubble is included", async () => {
+ const calls: string[] = []
+
+ const fork = createForkAction({
+ open: () => calls.push("dialog"),
+ messages: () => [{ id: "msg_1" }, { id: "msg_2" }, { id: "msg_3" }],
+ fork: (input) => {
+ calls.push(`fork:${input.sessionID}:${input.messageID}`)
+ return Promise.resolve({ id: "ses_fork" })
+ },
+ navigate: (sessionID) => calls.push(`navigate:${sessionID}`),
+ })
+
+ await fork({ sessionID: "ses_1", messageID: "msg_2" })
+
+ expect(calls).toEqual(["fork:ses_1:msg_3", "navigate:ses_fork"])
+ })
+
+ test("omits the cutoff when forking from the final bubble", async () => {
+ const calls: string[] = []
+
+ const fork = createForkAction({
+ open: () => calls.push("dialog"),
+ messages: () => [{ id: "msg_1" }, { id: "msg_2" }],
+ fork: (input) => {
+ calls.push(`fork:${input.sessionID}:${input.messageID ?? "full"}`)
+ return Promise.resolve({ id: "ses_fork" })
+ },
+ navigate: (sessionID) => calls.push(`navigate:${sessionID}`),
+ })
+
+ await fork({ sessionID: "ses_1", messageID: "msg_2" })
+
+ expect(calls).toEqual(["fork:ses_1:full", "navigate:ses_fork"])
+ })
+
+ test("falls back to the dialog without an explicit bubble anchor", async () => {
+ const Sentinel = () => null
+ let opened: unknown
+
+ const fork = createForkAction({
+ open: (component) => {
+ opened = component
+ },
+ loadDialog: () => Promise.resolve({ DialogFork: Sentinel }),
+ })
+
+ await fork()
+
+ expect(opened).toBe(Sentinel)
+ })
+})
+
+describe("forkCutoffMessageID", () => {
+ test("returns the message after the clicked anchor", () => {
+ expect(forkCutoffMessageID([{ id: "msg_1" }, { id: "msg_2" }, { id: "msg_3" }], "msg_2")).toBe("msg_3")
+ })
+
+ test("returns undefined for the last or missing anchor", () => {
+ expect(forkCutoffMessageID([{ id: "msg_1" }, { id: "msg_2" }], "msg_2")).toBeUndefined()
+ expect(forkCutoffMessageID([{ id: "msg_1" }, { id: "msg_2" }], "msg_missing")).toBeUndefined()
+ })
+})
+
describe("createOpenSessionFileTab", () => {
test("activates the opened file tab", () => {
const calls: string[] = []
diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts
index e6cb13c9..29736715 100644
--- a/packages/app/src/pages/session/helpers.ts
+++ b/packages/app/src/pages/session/helpers.ts
@@ -1,4 +1,4 @@
-import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
+import { batch, createMemo, onCleanup, onMount, type Accessor, type Component } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { Part, UserMessage } from "@deepagent-code/sdk/v2"
@@ -42,6 +42,47 @@ export function turnPreview(parts: Part[]): TurnPreview {
/** The turn rail only shows when there's more than one turn to navigate. */
export const shouldRenderTurnRail = (turnCount: number) => turnCount > 1
+export const forkCutoffMessageID = (messages: { id: string }[], messageID: string) => {
+ const index = messages.findIndex((message) => message.id === messageID)
+ if (index < 0) return undefined
+ return messages[index + 1]?.id
+}
+
+/**
+ * Build the reply-bubble "fork" action. The clicked bubble is the anchor, so
+ * the new session includes messages through that bubble. Session.fork treats
+ * `messageID` as the first message NOT copied, so the UI passes the next
+ * message as the cutoff; no next message means a full-history fork.
+ */
+export const createForkAction =
+ (deps: {
+ open: (component: DialogForkComponent) => void
+ loadDialog?: () => Promise<{ DialogFork: DialogForkComponent }>
+ messages?: (sessionID: string) => { id: string }[]
+ fork?: (input: { sessionID: string; messageID?: string }) => Promise<{ id: string } | undefined>
+ navigate?: (sessionID: string) => void
+ onError?: (error: unknown) => void
+ }) =>
+ (input?: { sessionID: string; messageID: string }) => {
+ if (input && deps.messages && deps.fork && deps.navigate) {
+ const navigate = deps.navigate
+ return deps
+ .fork({
+ sessionID: input.sessionID,
+ messageID: forkCutoffMessageID(deps.messages(input.sessionID), input.messageID),
+ })
+ .then((session) => {
+ if (session) navigate(session.id)
+ })
+ .catch((error: unknown) => deps.onError?.(error))
+ }
+
+ const load = deps.loadDialog ?? (() => import("@/components/dialog-fork"))
+ return load().then((mod) => deps.open(mod.DialogFork))
+ }
+
+type DialogForkComponent = Component
+
/**
* Fisheye width (px) for a turn rail segment given how far it sits from the
* hovered segment. The hovered segment grows to `peak`; neighbours shrink
diff --git a/packages/app/src/pages/session/message-timeline.data.ts b/packages/app/src/pages/session/message-timeline.data.ts
index 63c74f21..16ec5b1b 100644
--- a/packages/app/src/pages/session/message-timeline.data.ts
+++ b/packages/app/src/pages/session/message-timeline.data.ts
@@ -6,6 +6,12 @@ import { Data, Equal } from "effect"
export type SummaryDiff = SnapshotFileDiff & { file: string }
export type TimelineRowMap = {
+ // Full-width "derived from ‹parent›" banner shown at the very top of a forked session's
+ // transcript. Data comes from Session.metadata.forkedFrom (set by the backend fork()).
+ ForkBanner: {
+ parentSessionID: string
+ parentTitle: string
+ }
CommentStrip: {
userMessageID: string
previousUserMessage: boolean
@@ -32,6 +38,10 @@ export type TimelineRowMap = {
}
export namespace TimelineRow {
+ export class ForkBanner extends Data.TaggedClass("ForkBanner")<{
+ parentSessionID: string
+ parentTitle: string
+ }> {}
export class CommentStrip extends Data.TaggedClass("CommentStrip")<{
userMessageID: string
previousUserMessage: boolean
@@ -68,6 +78,7 @@ export namespace TimelineRow {
export class BottomSpacer extends Data.TaggedClass("BottomSpacer")<{}> {}
export type TimelineRow =
+ | ForkBanner
| CommentStrip
| UserMessage
| TurnDivider
@@ -80,6 +91,8 @@ export namespace TimelineRow {
export const key = (row: TimelineRow) => {
switch (row._tag) {
+ case "ForkBanner":
+ return `fork-banner:${row.parentSessionID}`
case "CommentStrip":
return `comment-strip:${row.userMessageID}`
case "UserMessage":
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index 379a09bf..c910fc2e 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -78,7 +78,9 @@ const emptyTools: ToolPart[] = []
const emptyAssistantMessages: AssistantMessage[] = []
const idle = { type: "idle" as const }
-type FramedTimelineRow = Exclude
+// ForkBanner and BottomSpacer render their own top-level container (no TimelineRowFrame, which
+// assumes a per-turn `userMessageID`), so they're excluded from the framed-row union.
+type FramedTimelineRow = Exclude
type TimelineRowByTag = Extract
function sameKeys(a: readonly string[] | undefined, b: readonly string[] | undefined) {
@@ -382,6 +384,15 @@ export function MessageTimeline(props: {
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
+ // Fork lineage carried on the session's own metadata (set by backend fork()). Drives the
+ // full-width "derived from ‹parent›" banner at the top of the forked transcript.
+ const forkedFrom = createMemo(() => {
+ const value = info()?.metadata?.forkedFrom as
+ | { parentSessionID?: string; parentTitle?: string }
+ | undefined
+ if (!value?.parentSessionID) return undefined
+ return { parentSessionID: value.parentSessionID, parentTitle: value.parentTitle ?? "" }
+ })
const parent = createMemo(() => {
const id = parentID()
if (!id) return
@@ -435,7 +446,11 @@ export function MessageTimeline(props: {
const timelineRows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => {
const rows = messageRowMemos().flatMap((memo) => memo())
if (rows.length === 0) return rows
- return reuseTimelineRows(previous, [...rows, new TimelineRow.BottomSpacer()])
+ const origin = forkedFrom()
+ const banner = origin
+ ? [new TimelineRow.ForkBanner({ parentSessionID: origin.parentSessionID, parentTitle: origin.parentTitle })]
+ : []
+ return reuseTimelineRows(previous, [...banner, ...rows, new TimelineRow.BottomSpacer()])
})
const timelineRowKeys = createMemo(() => timelineRows().map(TimelineRow.key), [] as string[], { equals: sameKeys })
const virtualCache = createMemo(() => readTimelineCache(sessionKey(), timelineRowKeys()))
@@ -1073,6 +1088,7 @@ export function MessageTimeline(props: {
onToolOpenChange={(open) => setToolOpen(part().id, open)}
deferToolContent={false}
virtualizeDiff={false}
+ onFork={props.actions?.fork}
/>
)}
@@ -1117,6 +1133,39 @@ export function MessageTimeline(props: {
const renderTimelineRow = (row: Accessor) => {
switch (row()._tag) {
+ case "ForkBanner": {
+ const forkBannerRow = row as Accessor>
+ const label = createMemo(() => {
+ const title = sessionTitle(forkBannerRow().parentTitle) || forkBannerRow().parentTitle
+ return title
+ ? language.t("session.fork.derivedFrom", { title })
+ : language.t("session.fork.derivedFromUnknown")
+ })
+ return (
+