diff --git a/__mocks__/obsidian.js b/__mocks__/obsidian.js
index ab3b8f61..217752d4 100644
--- a/__mocks__/obsidian.js
+++ b/__mocks__/obsidian.js
@@ -53,10 +53,16 @@ module.exports = {
this.write = jest.fn();
this.exists = jest.fn().mockResolvedValue(true);
this.mkdir = jest.fn().mockResolvedValue(undefined);
+ this.list = jest.fn().mockResolvedValue({ files: [], folders: [] });
+ this.remove = jest.fn().mockResolvedValue(undefined);
}
getBasePath() {
return this._basePath;
}
+ getFullPath(p) {
+ const rel = String(p).replace(/\\/g, "/").replace(/^\/+/, "");
+ return rel ? `${this._basePath}/${rel}` : this._basePath;
+ }
},
normalizePath: (p) => String(p).replace(/\\\\/g, "/").replace(/\/+/g, "/"),
parseYaml: jest.fn().mockImplementation((content) => {
@@ -70,6 +76,16 @@ module.exports = {
this.onClose = jest.fn();
}
},
+ // Base class for FolderSearchModal & friends; subclasses only need it to be
+ // constructable so suites that pull them into the module graph can load.
+ FuzzySuggestModal: class FuzzySuggestModal {
+ constructor(app) {
+ this.app = app;
+ this.open = jest.fn();
+ this.close = jest.fn();
+ this.setPlaceholder = jest.fn();
+ }
+ },
App: jest.fn().mockImplementation(() => ({
workspace: {
getActiveFile: jest.fn(),
diff --git a/__mocks__/react-resizable-panels.js b/__mocks__/react-resizable-panels.js
new file mode 100644
index 00000000..7cb8a22b
--- /dev/null
+++ b/__mocks__/react-resizable-panels.js
@@ -0,0 +1,15 @@
+// react-resizable-panels ships ESM-only (no CJS dist entry), which Jest can't
+// parse. Tests never exercise the resize behavior — they only pull the package
+// in transitively via UI modals — so stub the three primitives `resizable.tsx`
+// consumes with passthrough components that render their children.
+import React from "react";
+
+const passthrough = (displayName) => {
+ const Component = ({ children, ...props }) => React.createElement("div", props, children);
+ Component.displayName = displayName;
+ return Component;
+};
+
+export const PanelGroup = passthrough("PanelGroup");
+export const Panel = passthrough("Panel");
+export const PanelResizeHandle = passthrough("PanelResizeHandle");
diff --git a/designdocs/AGENT_HOME_ARCHITECTURE.md b/designdocs/AGENT_HOME_ARCHITECTURE.md
index a3e7cb52..9d63c5d7 100644
--- a/designdocs/AGENT_HOME_ARCHITECTURE.md
+++ b/designdocs/AGENT_HOME_ARCHITECTURE.md
@@ -1,93 +1,43 @@
-# Agent Home Architecture (PR1)
+# Agent Home & Project Workspace Architecture
-How the Agent Mode chat surface is structured after the "Agent Home" PR1
-(frontend-only) refactor. PR1 dismantled the monolithic `AgentChat` into a
-persistent shell plus focused leaf components, added a centered landing homepage
-for empty sessions, and introduced a landing with a **read-only Projects list**
-and a **fully manageable Recent Chats list** (the latter reuses the existing
-`ChatHistoryPopover`, so it gains search / rename / delete / open-source with no
-new backend). No backend, persistence, or project-scope behavior changed in PR1.
+How the Agent Mode chat surface is structured, from the "Agent Home" landing
+shell through project-scoped workspaces. The work landed in two increments on a
+single axis — **does it touch the backend?**
-## Scope: PR1 vs PR2
+- **PR1 (frontend-only)** dismantled the monolithic `AgentChat` into a persistent
+ shell plus focused leaf components, added a centered landing homepage for empty
+ sessions, and introduced a landing with a read-only Projects list and a fully
+ manageable Recent Chats list. No backend, persistence, or project-scope
+ behavior changed.
+- **PR2 (project workspaces + backend)** turned Agent Mode "projects" into Claude
+ Projects-style workspaces: entering a project gives a scoped workspace with its
+ own sessions, chat history, and materialized context, while the global
+ workspace becomes one special scope sharing the same code path.
-The full Agent Home vision (project workspaces, per-project sessions, context
-indexing) is large and almost all the risk lives in the backend. So the work is
-split on a single axis — **does it touch the backend?** PR1 is the pure-frontend
-slice; PR2 is the project workspace plus all backend work.
+This doc describes the **current** architecture (PR1 + PR2). Two follow-on areas —
+a built-in Obsidian MCP tool surface, and Outputs / Discover-URLs — are **PR3**
+and are not covered here.
-| Capability | PR1 | PR2+ |
-| -------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------- |
-| AgentHome layout shell | ✅ build (no-project state only) | + project state |
-| No-project landing (centered composer → pin to bottom) | ✅ | — |
-| Landing title + backend·mode subtitle | ✅ | — |
-| Multi-session tabs (`AgentTabStrip`) | ✅ reuse, untouched | project-scoped |
-| Recent Chats section (global `getChatHistoryItems`) | ✅ inline 3 + full `ChatHistoryPopover` View-all | per-project filter |
-| Dismantle AgentChat → transcript / input + hooks | ✅ | — |
-| Per-session drafts (replacing `key` remount) | ✅ queue survives switch, foreground flush | background queue auto-flush (sink to session layer) |
-| Projects section (read-only list + read-only sort) | ✅ inline 3 + `+` → coming-soon | — |
-| Read-only Project Picker (View-all + search + lazy paging) | ✅ | — |
-| Shared section primitives (`AgentHomeSection`/`Row`/`ViewAll`) | ✅ new | project reuse |
-| Optimizations ported from existing components | ✅ (see below) | — |
-| Project click | ✅ **coming-soon `Notice` only** | real project entry |
-| Project workspace (header + per-project sessions + Context) | ❌ | ✅ |
-| Sort-strategy switcher (writes `settings`) | ❌ | ✅ |
-| Create / rename / delete / duplicate / archive + `⋯` menu | ❌ | ✅ |
-| Chat rename / delete / open-source (chat View-all popover) | ✅ reuse `ChatHistoryPopover` | + project-scoped history |
-| Chat time-group headers (Today / Yesterday / 1w ago) | ✅ (via `ChatHistoryPopover`) | — |
-| Mobile always-visible row actions (chat View-all) | ✅ (via `ChatHistoryPopover`) | + project rows |
-| Drag-to-add context | ❌ | ✅ |
-| Welcome onboarding card | ❌ | ✅ |
-| `setCurrentProject` / `projectId` on sessions / usage touch | ❌ | ✅ |
-| Project scope envelope / `scopeStatus` gate | ❌ | ✅ |
-| Switch project = switch history | ❌ | ✅ |
-| Indexing card / permission above composer | ❌ | ✅ |
-| Outputs generation / Discover URLs | ❌ | PR2+ / later |
+## Increments at a glance
-**Why project click is coming-soon in PR1, not a real entry:** a project's full
-form is "project header + its own per-project sessions", which requires
-`AgentSessionManager` to know each session's `projectId` — backend-only, hence
-PR2. If PR1 forced a project page it would be a chimera: global multi-session
-tabs on top, one project in the middle. So PR1 stops at the read-only list.
+| Capability | PR1 | PR2 |
+| --------------------------------------------------------- | ------------------------------------------ | --------------------------------------- |
+| AgentHome layout shell, persistent (no `key` remount) | ✅ build (no-project state) | + project state |
+| No-project landing (centered composer → pin to bottom) | ✅ | — |
+| Multi-session tabs (`AgentTabStrip`) | ✅ reuse, global | project-scoped |
+| Recent Chats section | ✅ inline 3 + `ChatHistoryPopover` | per-scope |
+| Per-session compose drafts | ✅ queue survives switch, foreground flush | session-layer auto-flush still deferred |
+| Projects section | ✅ read-only list + picker | real entry + CRUD |
+| Enter a project (header + per-project sessions + Context) | ❌ coming-soon `Notice` | ✅ |
+| `projectId` on sessions, scoped history, scope cwd | ❌ | ✅ |
+| Project context materialization (URL/YouTube/PDF) | ❌ | ✅ off-vault shared cache |
+| Project instructions (`project.md` + `AGENTS.md` mirror) | ❌ | ✅ |
+| Drag-to-add context, Welcome onboarding card | ❌ | ✅ |
+| Built-in MCP tool surface, Outputs / Discover URLs | ❌ | PR3 |
-## Why dismantle AgentChat (approach A), not parallel-isolation (B)
+---
-The design handoff laid out two ways to land the same UI:
-
-- **A — direct refactor:** dismantle the monolithic `AgentChat` into the shell +
- leaf components + hooks described below (what PR1 did).
-- **B — parallel isolation:** build `AgentHome` alongside an untouched
- `AgentChat`, switch between them with a feature flag, and delete the old path
- in a later cleanup PR.
-
-The handoff **recommended B** for the dense-iteration period, on the grounds
-that A's defining cost is _high conflict — textual merge conflicts plus silent
-semantic conflicts_ — whenever someone keeps changing `AgentChat` in parallel.
-
-The executed direction was: ship Agent Home as a standalone, frontend-only PR
-(home + chat history first, project workspace on top later), accepting that it
-**touches** `AgentChat`. That was implemented as **A** (dismantle), not B. The
-"why A over B" was never written down at decision time; this note records it
-retroactively so the tradeoff is visible.
-
-**A's predicted cost materialized — and is by design.** While PR1 was in flight
-the base gained `#2530` (inline ask-user questions), `#2531` (copy/insert
-message actions, which removed message-delete), and `#2533` (no-global-app:
-`app.vault`→`app`) — all of which modified the now-deleted `AgentChat`. Rebasing
-PR1 onto that base surfaced **one** textual conflict (the modify/delete on
-`AgentChat.tsx`, resolved by `git rm`) but required **manually porting** those
-three deltas into the split files (runtime hook / composer) — git does **not**
-flag those, because the new files don't textually overlap the deleted one.
-
-**Standing consequence:** with `AgentChat` gone there is no single merge point.
-Any future base change to message-stream or composer behavior must be
-re-threaded into the split files by hand. That is the inherent, accepted cost of
-choosing A; B would have deferred it to the cleanup PR instead. The split is
-otherwise behavior-equivalent to the old monolith (see "Migration invariants").
-A later rebase onto a base carrying `#2537` / `#2540` / the new-logo commit
-needed **zero** re-thread — those land entirely in layers orthogonal to `ui/`
-(backends / skills / plugin wiring, no `ui/` edits), so it was textually clean
-with `tsc` and the agentMode test suite green. The re-thread tax only comes due
-when a base change actually touches message-stream or composer behavior.
+# Part I — Agent Home shell (PR1, frontend)
## Component tree
@@ -96,289 +46,355 @@ CopilotAgentView
└── AgentModeChat preload gate · auto-spawn · no-session fallback
└── AgentHome persistent shell for the active session
└── ChatInputProvider
- ├── AgentTabStrip multi-session tabs (unchanged)
+ ├── AgentTabStrip multi-session tabs
├── AgentModeStatus install / boot status pill
├── (landing header)* landing title + backend·mode subtitle
│ ⟷ AgentChatMessages (conversation state — reused base leaf)
├── AgentChatInput composer: controlled by a `draft` prop; send/queue/stop
- ├── AgentChatControls new chat / save / history (conversation state)
- └── (landing sections)* ProjectPickerList · GlobalRecentChatsSection
+ ├── AgentChatControls new chat / save / history
+ └── (landing sections)* project/recent-chat shelves
```
-`*` The landing header and landing sections are inlined in `AgentHome` rather
-than a standalone `AgentLandingPane` file — they are static layout, and a
-separate component would only add indirection. The shared section building
-blocks live in `AgentHomeSection.tsx` (see "Shared section primitives").
+`*` The landing header and sections are inlined in `AgentHome` (static layout; a
+separate component would only add indirection). The shared section building
+blocks live in `AgentHomeSection.tsx`.
-In the conversation state `AgentHome` renders the reused `AgentChatMessages`
-leaf **directly** — there is no thin transcript wrapper. An earlier
-`AgentChatTranscript` pass-through was removed because it owned nothing (1:1
-prop forward) and its `memo` was redundant: the real per-token boundary is
-`AgentChatMessages`'s own `memo` plus the memoized `AgentChatInput`. Message
-content, the scroll container, the empty state, and the plan/tool/ask-user tail
-cards all stay in `AgentChatMessages` (base, reused, also rendered by the
-non-agent chat) — pulling them up into an Agent-only layer would fork a
-base-maintained component and re-create merge conflicts. If PR2 needs
-Agent-specific transcript chrome that should _not_ live in the shared base
-(timeline grouping, "load older", etc.), reintroduce a transcript layer then —
-with real responsibilities, not as an empty seam.
+In the conversation state `AgentHome` renders the reused `AgentChatMessages` leaf
+**directly** — no thin transcript wrapper. An earlier `AgentChatTranscript`
+pass-through was removed because it owned nothing (1:1 prop forward) and its
+`memo` was redundant: the real per-token boundary is `AgentChatMessages`'s own
+`memo` plus the memoized `AgentChatInput`. Message content, the scroll container,
+the empty state, and the plan/tool/ask-user tail cards stay in
+`AgentChatMessages` (base, reused, also rendered by the non-agent chat) — pulling
+them into an Agent-only layer would fork a base-maintained component and
+re-create merge conflicts.
### Why `AgentHome` is persistent (no `key` remount)
Previously `AgentModeChat` rendered ``, so
-switching tabs remounted the whole surface and discarded any unsent input. PR1
-removes that key: `AgentHome` persists and the tab strip swaps the `sessionId` /
-`backend` props instead. Per-session input state lives in a draft store owned by
-`AgentHome` (below) and passed down to the composer, so switching tabs swaps the
-active draft rather than throwing it away.
+switching tabs remounted the whole surface and discarded unsent input. The shell
+is now persistent: the tab strip swaps the `sessionId` / `backend` props instead.
+Per-session input state lives in a draft store owned by `AgentHome` and passed to
+the composer, so switching tabs swaps the active draft rather than throwing it
+away.
## State ownership
-| State | Owner | Notes |
-| ------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `messages`, `isStarting`, plan/permission, ask-user-questions | `useAgentChatRuntimeState(backend)` | Single backend subscription; one `sync()` keeps all fields consistent (incl. `pendingAskUserQuestions`). `messages` flow only to `AgentChatMessages`, which (with the memoized composer) is the per-token re-render boundary. |
-| chat history items + handlers | `useAgentHistoryControls(manager, plugin)` | load / open / rename / delete / open-source. |
-| per-session compose drafts | `useAgentInputDrafts({ activeSessionId, liveSessionIds, defaultIncludeActiveNote })` | input / images / contextNotes / include-flags / `loading` / `queue`, keyed by session id. **Owned by `AgentHome`**, which passes the (referentially stable) controls object down to `AgentChatInput` as a `draft` prop. |
-| active turn `loading`, drag overlay | `AgentHome` (owns `useAgentInputDrafts` + `useChatFileDrop` directly) | Reads `draft.loading` (transcript spinner) and `isDragActive` (whole-area overlay) straight from the hooks it owns — **not** mirrored up from the composer via effect callbacks. |
+| State | Owner | Notes |
+| ------------------------------------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `messages`, `isStarting`, plan/permission, ask-user-questions | `useAgentChatRuntimeState(backend)` | Single backend subscription; one `sync()` keeps all fields consistent. `messages` flow only to `AgentChatMessages`, the per-token re-render boundary. |
+| chat history items + handlers | `useAgentHistoryControls(manager, plugin)` | load / open / rename / delete / open-source. |
+| per-session compose drafts | `useAgentInputDrafts(...)` | input / images / contextNotes / include-flags / `loading` / `queue`, keyed by session id. Owned by `AgentHome`, passed to `AgentChatInput` as a referentially-stable `draft` prop. |
+| active turn `loading`, drag overlay | `AgentHome` | Reads `draft.loading` and `isDragActive` straight from the hooks it owns — not mirrored up from the composer via effect callbacks. |
### Migration invariants (must survive dismantling AgentChat)
-Splitting the monolithic `AgentChat` into shell + leaves must NOT drop these
-behaviors; each was load-bearing in the original and is easy to lose in a
-refactor:
+Splitting the monolith into shell + leaves must NOT drop these load-bearing
+behaviors:
-- **Plan/permission gating** — while a plan permission is pending the composer
- is inert: `hasPendingPlanPermission` wraps `AgentChatInput`'s output in
- `pointer-events-none` (+ dimmed), and the permission card still resolves.
-- **`onSaveChat` registration** — the global autosave path
- (`CopilotAgentView.saveChat` → `manager.saveActiveSession()`) must always be
+- **Plan/permission gating** — while a plan permission is pending the composer is
+ inert (`pointer-events-none` + dimmed) and the permission card still resolves.
+- **`onSaveChat` registration** — the global autosave path must always be
registered against the **current active session**, never a stale one.
-- **Whole-area file drop** — `AgentHome` runs `useChatFileDrop` bound to the
- chat container (`chatContainerRef`), not just the composer, so files dropped
- anywhere in the chat area attach to the active draft (the drop hook writes the
- draft's `contextNotes`/`images` that `AgentHome` owns), and `isDragActive`
- drives the whole-area overlay.
+- **Whole-area file drop** — `useChatFileDrop` is bound to the chat container, so
+ files dropped anywhere in the chat area attach to the active draft, and
+ `isDragActive` drives the whole-area overlay.
-### Two-state derivation
+### Two-state derivation & composer placement
-`AgentModeChat` owns the no-session fallback (binary missing / booting / boot
-error). Within an active session, `AgentHome` derives `isGlobalLanding`
-(exposed as `data-agent-landing="global" | "conversation"`):
+Within an active session, `AgentHome` derives `isGlobalLanding` (exposed as
+`data-agent-landing`):
-- **global landing** — active session has no user-visible messages
- (`!session.hasUserVisibleMessages()`): centered title + composer + landing
- sections. "Global" distinguishes this no-project landing from the per-project
- landing PR2 adds (`data-agent-landing="project"`).
-- **conversation** — the session has messages: transcript fills, composer
- pinned bottom.
+- **global landing** — active session has no user-visible messages: centered
+ title + composer + landing sections.
+- **conversation** — the session has messages: transcript fills, composer pinned
+ to bottom.
-Because the runtime subscription re-renders `AgentHome` as the stream updates,
-this re-derives the instant the first user message lands.
-
-### Composer placement (center ⟷ bottom)
-
-`AgentChatInput` is a **position-stable node at a fixed sibling index**. The
-slots around it toggle (`landing header | transcript`, `null | controls`,
-composer, `landing sections | null`); on the landing the column scrolls as one
-unit (`overflow-y-auto` + a shared `tw-px-2`) with flex spacers above/below the
-composer keeping it centered (biased lower), while in conversation the
-transcript (`flex-1`) pushes the composer to the bottom. The composer never
-remounts across the flip, so the draft and focus survive. (The flip is currently
-instant; a smooth transition is a future polish.)
+The composer (`composerNode`) is the same element in both branches, but it sits
+at a different tree position in each, so it **remounts** on the
+landing→conversation flip. That is safe because the flip only fires right after a
+send (which already reset the draft) or on a chat load (which changes `sessionId`
+and remounts anyway), and the per-session draft lives in `AgentHome` — not in the
+composer — so it survives the remount.
### Per-session draft lifecycle
- **switch session** → load that session's draft (or a fresh default seeded from
- `autoAddActiveContentToContext`); global `selectedTextContexts` are cleared so
- a selection can't drift between sessions.
-- **send** → snapshot context into the queued item, then `resetCompose()` clears
- the compose box (input / images / notes / flags); `loading` + `queue` are
- per-session so a backgrounded turn never bleeds into the foregrounded session.
+ `autoAddActiveContentToContext`); global `selectedTextContexts` are cleared.
+- **send** → snapshot context into the queued item, then `resetCompose()`;
+ `loading` + `queue` are per-session so a backgrounded turn never bleeds into the
+ foregrounded session.
- **turn resolves after a tab switch** → the send-time `setLoading` closure is
- bound to the originating session, and a live-session guard prevents a late
- update from resurrecting a closed/replaced session's draft.
+ bound to the originating session; a live-session guard prevents a late update
+ from resurrecting a closed/replaced session's draft.
- **close / `replaceSessionInPlace`** → the session id leaves `liveSessionIds`;
- the draft (and its `File[]`) is pruned. `replaceSessionInPlace` mints a new id,
- so "new chat" naturally lands on a fresh empty draft.
-- `selectedTextContexts` is intentionally **not** in the draft — it is a global
- ephemeral atom, snapshotted into the queued message at send time.
+ the draft (and its `File[]`) is pruned.
### Backend restart (how the UI absorbs it)
-A backend can be restarted at runtime so its next spawn picks up new config:
-binary path / install changes, system-prompt changes, provider config (filtered
-by `restartOnProviderConfigChange`), and — added by `#2537` — a Copilot Plus
-sign-in/out or license rotation, which restarts **every** backend (unfiltered),
-because the decrypted license is injected into each backend's spawn env. All of
-these funnel through `AgentSessionManager.restartBackend → restartBackendNow`.
+A backend can be restarted at runtime (binary/install change, system-prompt
+change, filtered provider config, or a Copilot Plus sign-in/out that restarts
+every backend). All funnel through `AgentSessionManager.restartBackend →
+restartBackendNow`. The UI needs **no special restart listener**: a restart rides
+the same seam every session change uses — `manager.subscribe()` → `AgentModeChat`
+re-reads `getActiveSession()` and feeds fresh props to `AgentHome`.
+`restartBackendNow` closes every session on that backend (draining a pending
+auto-save first), so their drafts are pruned; only when the _active_ session was
+on that backend is one fresh replacement created. An in-flight turn is not
+interrupted — the restart defers until the busy session goes idle.
-The UI needs **no special restart listener**: a restart rides the same seam
-every session change uses — `manager.subscribe()` → `AgentModeChat` re-reads
-`getActiveSession()` / `getActiveChatUIState()` → feeds fresh `sessionId` /
-`backend` to `AgentHome`. `restartBackendNow` closes every session on that
-backend (`closeSession`, which first drains a pending auto-save), so their ids
-leave `liveSessionIds` and their drafts are pruned by `useAgentInputDrafts`;
-only when the _active_ session was on that backend is one fresh replacement
-session created. An in-flight turn is **not** interrupted — the restart defers
-until the busy session goes idle.
+PR2 keeps project / scope state **derived from the manager through this same
+subscribe seam** (never cached independently in `AgentHome`), so a restart can't
+strand it; the replacement session inherits the replaced session's `projectId`
+(see Part II).
-Consequences worth knowing (session-layer behavior, **not** PR1-introduced — the
-legacy `AgentChat` took the same path and additionally discarded drafts on its
-`key` remount):
+## Landing sections, shared primitives & asset reuse
-- A Plus-license change restarts all backends, so **every open tab closes at
- once**: the active backend keeps one fresh empty session (which re-derives
- `isGlobalLanding` → the landing reappears), the rest just vanish. Unsent
- drafts / queued follow-ups in every closed tab are lost.
-- A closed conversation survives on disk **only if `autosaveChat` is on**
- (`closeSession` drains the debounced auto-save, which is gated on that
- setting); with it off, an in-progress chat that was never manually saved is
- gone. This is the sharp edge — a Plus sign-in resets whatever you were doing.
+The landing's two shelves sit at different capability levels on purpose: in PR1
+Projects was read-only (a coming-soon `Notice`) because real project management
+is backend work, while Recent Chats was fully manageable by reusing the existing
+`ChatHistoryPopover` (search / rename / delete / open-source). The shared shelf
+building blocks live in `AgentHomeSection.tsx` (`AgentHomeSection` /
+`AgentHomeListRow` / generic `AgentHomeViewAll` with lazy
+`IntersectionObserver` paging), kept generic so PR2's project-scoped shelves
+reuse them.
-**PR2 caution.** When sessions gain a `projectId` and history becomes
-project-scoped, keep project / scope state **derived from the manager through
-this same subscribe seam** — don't cache it independently in `AgentHome`, or a
-restart strands it. And `restartBackendNow`'s replacement session has no
-`projectId` today; PR2 must decide which project it belongs to (and whether to
-auto-replace inside a project at all, versus falling back to the project
-landing). The deferred "background-session queue auto-flush" seam must likewise
-survive — or explicitly accept losing — a restart that closes background
-sessions.
+The reuse rule of thumb: **a pure function, read-only selector, or side-effect-free
+hook is reused; a controller component or a hook that writes global state is
+rebuilt.** `ChatInput` is a shared hotspot (Agent + legacy Chat both render it),
+so it stays a black box — centering vs pinning is solved by the outer layout's
+CSS, never by editing `ChatInput`.
-## Landing sections (Projects + Recent Chats)
+---
-Visible only in the global-landing state. The two sections sit at **different
-capability levels on purpose**: Projects is read-only because real project
-management (CRUD, entering a project) is PR2 backend work, while Recent Chats is
-fully manageable because chat management already exists in PR1 — the conversation
-control bar uses the same `ChatHistoryPopover`, every handler already lives in
-`useAgentHistoryControls`, so the landing **reuses** it rather than shipping a
-weaker read-only twin.
+# Part II — Project workspaces (PR2, backend)
-- `ProjectPickerList` — **read-only.** `projects` from `useProjects()` (read-only
- `sortByStrategy("recent")` inside), inline list capped at `INLINE_LIMIT` (3)
- with an in-pane `AgentHomeViewAll` popover + search. A header `+` button and row
- clicks both fire a coming-soon `Notice` only — **no** `setCurrentProject`, no
- `touch`, no session creation, no history-scope change.
-- `GlobalRecentChatsSection` — **inline preview read-only, View-all fully
- manageable.** The inline 3 rows open-on-click and are pinned to
- `sortByStrategy("recent")` (the section is literally "Recent Chats"). The "View
- all chats" trigger opens the full `ChatHistoryPopover` — search, time grouping,
- rename, delete, open-source, backend icon — which follows the user's configured
- `chatHistorySortStrategy`. So the inline order (always recent) and the popover
- order (user setting) can differ by design. Every mutation routes through the
- same `useAgentHistoryControls` handlers the conversation control bar uses; no
- new backend.
+Entering a project gives it its own sessions, chat history, and materialized
+context. The global workspace is modeled as one special scope (the
+`GLOBAL_SCOPE` sentinel) running the same code path, so there is no separate
+"no-project" branch to keep in sync.
-## Shared section primitives
+## Session scope
-`AgentHomeSection.tsx` holds the building blocks the landing lists render
-through. Both sections share the header + inline-rows shape; they diverge at
-"View all" — Projects opens `AgentHomeViewAll`, Recent Chats opens the full
-`ChatHistoryPopover` (so `AgentHomeViewAll` currently has a **single consumer**,
-kept generic for the project-scoped section PR2 adds rather than specialized now):
+`AgentSessionManager` stores sessions in a `Map` keyed by an
+internal session id, but **binds each session to an immutable `projectId`**. All
+scope behavior keys off that `projectId`, not off the map key:
-- `AgentHomeSection` — section header (icon + title + bracketed count + optional
- trailing action) over its rows.
-- `AgentHomeListRow` — generic row: optional leading icon, truncated label
- (with full-text `title` tooltip), relative time (with absolute-time tooltip).
- No icon by default (the header carries the type icon); an icon is passed only
- when it's _informational_ — see the backend icon below.
-- `AgentHomeViewAll` — generic "View all" trigger + in-pane search
- popover, generic over the item type so the domain doesn't leak into the
- primitive. Pages the full list with `VIEW_ALL_PAGE_SIZE` (50) via an
- `IntersectionObserver` sentinel. **Used by the Projects "View all" only**; kept
- generic for PR2's project-scoped section (Recent Chats reuses the richer
- `ChatHistoryPopover` instead).
+- **per-scope MRU** and `getSessionsForScope(projectId)` — the tab strip and
+ history filter to the active scope.
+- `resolveSessionCwd` / `resolveScopeCwd` — a project's working directory is its
+ own folder (the directory holding its `project.md`); the global scope falls
+ back to the vault root.
+- `enterProject` / `exitProject` — switch the active scope.
+- **Active-session invariant**: `getActiveSession().projectId === activeProjectId`.
+ Existing UI helpers that read the active session stay correct without knowing
+ about scopes.
-### Optimizations ported from existing components
+Two behaviors preserve the invariant across the rough edges:
-PR1 (zero backend), lifted from mature components so the landing matches their
-proven behavior. These apply to what PR1 actually built — the inline rows and the
-Projects `AgentHomeViewAll`; the Recent Chats View-all gets the same behaviors
-natively because it _is_ the reused `ChatHistoryPopover`:
+- **Backend restart / in-place replacement inherits the replaced session's
+ `projectId`** — a project scope reuses its warm process instead of adopting the
+ vault-root probe session.
+- **Re-entering a project opens as a fresh visit**: in-progress conversational
+ chats detach from the tab strip (kept live in the pool, listed in chat history
+ with a running indicator) instead of dragging every prior tab back, while an
+ unused empty landing is reused rather than stacked. The global workspace keeps
+ its restore-the-last-tab behavior.
-- **Lazy pagination** (`IntersectionObserver` + sentinel, callback-ref for
- Radix's deferred mount) — copied from `ChatHistoryPopover` into
- `AgentHomeViewAll` (the Projects View-all) so a long list renders incrementally.
- The Recent Chats View-all is `ChatHistoryPopover` itself, so it paginates
- natively without this port.
-- **`sortByStrategy("recent")`** — from `ChatHistoryPopover` / the project list.
- The Projects list and the Recent Chats **inline preview** sort by it (last-used
- desc → created → name). The Recent Chats View-all is the real
- `ChatHistoryPopover`, which follows the user's `chatHistorySortStrategy`
- instead. The inline preview owns its sort because the PR1-frozen
- `getChatHistoryItems()` returns vault-scan order.
-- **Row tooltips** (full label + absolute time) — the glanceable compact labels
- stay; hover reveals the full value.
-- **Backend brand icon on Recent Chats rows** — reuses `backendRegistry[id].Icon`
- (same resolver as `AgentChatControls`), falling back to `MessageCircle` — the
- same fallback `ChatHistoryPopover` rows use, so the inline preview and the
- View-all match for legacy chats with no `backendId`. Projects rows stay
- icon-less; the chat icon is informational (which backend ran it), not a repeat
- of the section type.
+## Context materialization
-## Asset reuse decisions
+A project can attach **URL / YouTube** links and **in-vault binary files** (PDFs,
+Office docs, e-books, images — all materialized under the single `file` kind).
+Before a session
+opens, `projectContextMaterializer` captures them once per session
+(single-flight per project), converts each via brevilabs into a text snapshot,
+and builds a `` block that is inlined into the session's first
+user prompt. The block lists absolute paths to the snapshots so the agent can
+read them directly; the composer shows a context status icon and queues sends
+while context is still materializing.
-PR1 reused existing code only where it was safe to, and rebuilt where the old
-asset carried legacy-Chat semantics. The rule of thumb: **a pure function,
-read-only selector, or side-effect-free hook is reused; a controller component
-or a hook that writes global state is not** — anything that depends on
-legacy-Chat concepts (`useChatInput` / `showChatUI` / `setCurrentProject` /
-chain type) cannot enter Agent Home directly. `ChatInput` is a shared hotspot
-(Agent + legacy Chat both render it), so it stays a black box — centering vs
-pinning is solved by the outer layout's CSS, never by editing `ChatInput`.
+Folders, notes, tags, and extensions are listed in the same block by path/pattern
+(they need no conversion); folders that live outside the session cwd are also
+reported as `additionalDirectories` to widen the agent's searchable roots.
-| Asset | Decision | Why |
-| ----------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
-| `useProjects()` | reuse | Reactive read-only, stable ref; store is populated at `onLayoutReady`, so it has data even when only the Agent view is open. |
-| `sortByStrategy()` | reuse | Pure sort (reads `UsageTimestamps`, never touches it). |
-| `filterProjects()` | reuse (search) | Pure filter. |
-| `ProjectConfig` | reuse | Plain type. |
-| `ChatInput` | reuse, black box (no edits) | Controlled component; center/pin handled by outer CSS. |
-| `AgentTabStrip` | reuse, untouched | Takes only `manager`; already global-scoped in the no-project world. |
-| `ChatHistoryPopover` / `ChatHistoryItem` | reuse | Agent already uses them. |
-| `ProjectList.tsx` | **rebuild** (`ProjectPickerList`) | Legacy-Chat controller (`useChatInput` / `showChatUI` / `setCurrentProject`); read-only picker is a clean rewrite. |
-| `setCurrentProject` / `getCurrentProject` | **do not touch** | Generic name but carries legacy project-mode global semantics. |
-| `touchProjectLastUsed` / usage `touch` | **do not touch** | Write semantics; PR1 is read-only. |
-| `AddProjectModal` | PR2 reuse | Callbacks are clean (no legacy semantics), but project creation is PR2. |
+**Contract (relied on by the session manager):** materialization **never
+rejects** — any failure degrades to a best-effort partial / empty result so
+session start is never blocked. It is cheap on unchanged context (snapshots
+cheap-skip by fingerprint, known-bad sources cheap-skip by failure marker), and a
+`contextSignature` over the source fields drives reactivity: a re-entered project
+re-materializes only when its sources changed, and the active project warms its
+cache in the background.
-## PR2 seam (not implemented in PR1)
+### The shared off-vault conversion cache
-Structural seams already in place:
+Snapshots are the supporting subsystem behind context materialization. Each
+source is converted **once per vault** and shared across every project that
+references it, instead of once per project.
-- The `AgentHome` shell is in place; PR2 adds a project-workspace state as a new
- branch without disturbing the no-project skeleton.
-- The landing display components take only `items` / `projects` + callbacks, so
- PR2 can swap in project-scoped history without touching them.
-- `ProjectPickerList.onSelect` / `onCreate` flip from coming-soon `Notice`s to
- real project entry + creation; `GlobalRecentChatsSection` (global) sits beside
- a future project-scoped "Project Chats" without a naming clash.
+**Off-vault, per-vault layout.** The cache lives outside the vault — single copy,
+not synced by Obsidian Sync / git, never indexed as note content — under the
+existing per-vault namespace that already hosts the recent-chats index:
-Deferred to PR2 (write operations / backend — must NOT leak into the **Projects**
-read-only list; chat management already shipped via the reused
-`ChatHistoryPopover`):
+```
+~/.obsidian-copilot/vaults//context-cache/
+ remotes/ web-.md · youtube-.md # shared by all projects
+ files/ file-.md # shared by all projects
+ markers/ /failed--.json # failure markers, per project
+```
-- **Project CRUD**: create / rename / delete / duplicate / archive, the per-row
- `⋯` management menu, and the "name only" quick-create modal.
-- **`setCurrentProject` + project usage touch** — entering a project switches
- context and records recency; PR1 writes neither.
-- **`projectId` on sessions, scope envelope / `scopeStatus` gating,
- switch-project = switch-history, indexing card, drag-to-add context, Outputs.**
-- **Project-scoped chat history** — the chat management surface (rename / delete /
- open-source / time-group headers / mobile always-visible actions) already ships
- in PR1 for the global list via `ChatHistoryPopover`; PR2 only re-points it at a
- project-filtered list.
-- **Sort-strategy switcher** writing `settings` (PR1's Projects list and the
- Recent Chats inline preview pin `recent`; the chat View-all already follows the
- read-only `chatHistorySortStrategy`).
-- **Background-session queue auto-flush** — sink the per-session compose queue
- from the foreground composer down into the session layer
- (`AgentSession` / `AgentChatUIState`) so a background turn drains its own
- queued follow-ups when it finishes, instead of waiting for the user to return
- to that tab. PR1's queue lives in `AgentChatInput` (UI), which by design only
- observes the foreground session — see the DESIGN NOTE on the flush effect in
- `AgentChatInput.tsx`. PR1 is already strictly better than legacy here (legacy
- kept the queue in component `useState` and discarded it on tab-switch
- remount); this is an additive enhancement, not a regression fix, and belongs
- with the session-layer/backend work, not the read-only frontend.
+`vaultId = md5(adapter.getBasePath()).slice(0, 8)` (extracted as `getVaultId` in
+`utils/appPaths.ts`). Per-vault bucketing means the current vault's project
+registry is the cache's complete reference set, so garbage collection needs no
+cross-vault refs subsystem — at the cost of the same URL being converted once in
+each of two different vaults (rare). **All paths derive from one source of truth,
+`context/conversionsLocation.ts`** (`cacheRoot` / `remotesDir` / `filesDir` /
+`markersDir(projectId)`), shared by the writer and every reader.
+
+**Identity keys + internal freshness.** Snapshot filenames hash the **source
+identity** (`md5(url)` / `md5(vaultPath)`) — never content, never `projectId`.
+Freshness is a `fingerprint` stored in the snapshot's own metadata header, and it
+differs by kind: a **file** uses `mtime:size` (an edit changes the fingerprint, so
+the cheap-skip misses and the **same file is overwritten** — no orphans), while a
+**remote** uses its identity (`type:url`), so a successfully fetched URL is kept
+until its configured string changes. A URL's identity is the exact trimmed string
+the user configured — there is no semantic canonicalization (`x.com` ≠ `x.com/`).
+
+**Per-artifact lock.** A module-level lock (keyed by the snapshot filename, via
+`async-mutex`) wraps each source's whole read-decide-write. Because the key is
+identity-derived, the **same source across two projects maps to the same lock**:
+the first project to cold-convert acquires it, reads metadata (miss), fetches,
+atomically writes, releases; a second project waiting on the same source then
+acquires the lock, re-reads the now-present metadata, and **cheap-skips without
+re-fetching or overwriting**. Two projects cold-converting one URL converge to a
+single brevilabs call.
+
+**Failure markers (CAG-style negative cache).** A source that fails to
+fetch/parse with no usable snapshot writes a `failed-…json` marker in _its own
+project's_ marker bucket, carrying the error and (for files) the `mtime:size`
+fingerprint. A later automatic run cheap-skips that known-bad source — re-surfacing
+the stored error instead of re-hitting brevilabs every session — until the file
+changes or the user forces a retry (`forceRetryFailed`, the status popover's
+"Retry"). Markers are bucketed per project because a failure is meaningful only to
+the project that hit it; snapshots are shared, so they are **never reconciled
+against a single project's sources** (that would delete files other projects
+still use). Cross-project convergence is handled instead: when project B writes a
+shared snapshot for a source project A previously failed, A's next run cheap-skips
+the snapshot and best-effort clears its now-stale marker. A successful snapshot is
+kept indefinitely (no TTL) — the plugin can't observe an agent reading an absolute
+path, so a time-based or touch-on-read policy would either over-retain or delete
+context that is still in use.
+
+**Delivery to the three backends.** The shared cache lives outside every project
+cwd, so the only pointer all three backends can reach is an **absolute path**: the
+manifest lists each snapshot's absolute path (keyed by `type:source` so the same
+URL used as both a web and a YouTube source resolves to distinct snapshots), and
+agents read it with their native file tool. claude additionally accepts the cache
+root as an `additionalDirectories` entry; codex reads the whole disk; **opencode
+blocks reads outside the vault by default, so it is granted an
+`external_directory: { "/**": "allow" }`\*\* on its spawn agents.
+
+### The root-confined node:fs backend
+
+`ContextCacheFs` is a small injectable filesystem interface kept **node-free** (a
+pure type), so mobile-reachable code can import the type without pulling
+`node:fs` into the bundle. The single production implementation,
+`createNodeContextCacheFs`, is rooted at the absolute cache directory and loads
+`node:fs` / `node:path` **lazily via `require` inside the factory** — never at
+module top level — so the builtins are evaluated only behind the desktop Agent
+boundary. Three hard rules:
+
+- **Root-confined.** Internal paths are cache-root-relative (absolute paths are
+ resolved only in `conversionsLocation`); `resolveWithin` rejects `..` segments,
+ absolute inputs, and anything that resolves outside the root, and `clear` never
+ ascends to the parent `vaults//` (which holds `agent-chat-index.json`).
+ Symlink escape is documented as intentionally out of scope: every path is
+ plugin-derived md5 filenames, so no caller can inject one, and following a
+ pre-seeded symlink would require a local actor who already has write access to
+ this directory.
+- **Split best-effort policy.** `writeText` / `mkdirRecursive` **throw** — a
+ swallowed write would let the store report success while the snapshot is
+ missing (a manifest pointing at nothing). The store turns a write throw into a
+ per-source failure and a mkdir throw into a whole-run degradation. `list` /
+ `remove` / `clear` **tolerate** a missing target (`[]` / idempotent). `readText`
+ **passes the error through** — the store's `readMeta` / `readFailureMarker` and
+ the UI's tolerant helpers each `try/catch → null`, so read-as-miss tolerance
+ lives at the call sites, not the fs layer.
+- **Atomic writes.** `writeText` stages into a same-dir temp file then renames
+ over the target (with a short retry for transient Windows watcher/AV locks);
+ `list` ignores temp files. The cache is regenerable, so there is no `fsync`.
+
+### Mobile boundary
+
+The Agent-Mode context readers (status icon, content-conversion preview, the
+Clear command) are reachable from a shared modal that mobile also renders. They
+must never evaluate `node:fs` on mobile, so each reaches the node-touching modules
+(`conversionsLocation`, `contextCacheFs`) through a desktop-gated **dynamic
+`import`**, never a static top-level import. (Desktop-only paths that mobile can
+never reach — the materializer, the opencode descriptor — import the same modules
+statically, which is fine; only the mobile-reachable readers need the dynamic
+boundary.) On mobile the readers degrade to an empty state (there is no Agent Mode
+there anyway). A static-import smoke check (`scripts/mobile-load-smoke.cjs`) guards
+this boundary over the cache consumers.
+
+### Cleanup
+
+There is **no automatic garbage collection** in this version — the existing
+`Clear Copilot cache` command additionally wipes `context-cache/` (all three
+subdirectories) while leaving the sibling `agent-chat-index.json` intact. Marker
+cleanup happens incrementally (a stale marker is cleared when its source later
+succeeds, as above). The released CAG caches (`.copilot/*`) are a separate system
+and are left untouched. Source kinds are a single source of truth,
+`MATERIALIZED_SOURCE_TYPES` (`web` / `youtube` / `file`), so adding a kind updates
+the filename patterns and marker-pruning regex together.
+
+## Project instructions (`project.md` + `AGENTS.md` mirror)
+
+`project.md` is the single source of truth for a project's config and
+instructions. A **marker-gated `AGENTS.md` mirror** is generated alongside it by
+`ensureAgentsMirror`: codex and opencode auto-discover `AGENTS.md` from the
+session cwd, and claude receives the same composed instructions via
+`getProjectProfile`. A built-in project policy is layered into each project's
+instructions — for claude always; for codex/opencode through the generated
+mirror, which **yields to a user-authored, unmarked `AGENTS.md`** (the mirror only
+manages the file it owns, so it never clobbers a hand-written one).
+
+## History scope
+
+Agent chat history is scope-keyed: a project view lists only that project's
+chats, while `GLOBAL_SCOPE` is the flat all-chats view. A chat's scope is its
+frontmatter `projectId`; a legacy chat with no `projectId` resolves to
+`GLOBAL_SCOPE` (the hard contract is "absent/blank `projectId` → `GLOBAL_SCOPE`",
+never a filename-prefix guess). Native session history is scoped to projects too,
+and a resumed transcript hydrates from the session's scope cwd.
+
+Opening a saved chat from a different scope switches `activeProjectId` before
+resuming/creating its session. If the resume returns no session and the create
+then throws (e.g. a missing backend binary fails to spawn), the load would leave
+`activeProjectId` pointing at the new scope while `activeSessionId` still
+references the old one — breaking the active-session invariant.
+`rollbackHistoryLoadScope` restores the replaced scope on that failure, but only
+while still parked in the scope the load switched to (a concurrent switch during
+the awaited spawn means the user has moved on).
+
+## Hardening & cross-cutting
+
+- **`contextCacheFs` path guards** — `..`, absolute, and root-escaping paths are
+ rejected; symlink escape is a documented out-of-scope design note (above).
+- **Hard-disabled composer** — when the active project is orphaned (deleted out
+ from under the user), the composer blocks both keyboard sends and the
+ queued-message flush, not just pointer events, so a turn can't drain into a dead
+ project.
+- **Unified todo / plan** — backend todo lists across claude / codex / opencode
+ are normalized into one plan model feeding the project info popover, with
+ per-session todo-id tracking and symmetric plan-clear on empty.
+
+## Verification
+
+The cache and scope behavior are covered by unit + integration suites, including
+a real-filesystem dedup integration test that drives the production
+`createNodeContextCacheFs` and the real per-artifact lock against a temp
+directory (two projects sharing a URL → one snapshot on disk, one fetch; the same
+for a shared PDF; a failed project's marker cleared once another project
+materializes the source). The opencode `external_directory` allow injection, the
+mobile static-import boundary, and the three-backend snapshot read were validated
+against a real vault.
diff --git a/jest.config.js b/jest.config.js
index dc847068..fa7b91d1 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -15,6 +15,8 @@ module.exports = {
"^yaml$": "/node_modules/yaml/dist/index.js",
"^@agentclientprotocol/sdk$": "/__mocks__/@agentclientprotocol/sdk.js",
"^@anthropic-ai/claude-agent-sdk$": "/__mocks__/@anthropic-ai/claude-agent-sdk.js",
+ // react-resizable-panels is ESM-only with no CJS build to point at; stub it.
+ "^react-resizable-panels$": "/__mocks__/react-resizable-panels.js",
},
testRegex: ".*\\.test\\.(jsx?|tsx?)$",
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
diff --git a/scripts/mobile-load-smoke.cjs b/scripts/mobile-load-smoke.cjs
index ca13014b..dcb838b8 100644
--- a/scripts/mobile-load-smoke.cjs
+++ b/scripts/mobile-load-smoke.cjs
@@ -17,6 +17,12 @@ const loadCriticalFiles = [
"src/components/chat-components/plugins/slashMenuItems.ts",
];
+const contextCacheConsumerFiles = [
+ "src/commands/index.ts",
+ "src/components/project/agentProcessingAdapter.ts",
+ "src/utils/cacheFileOpener.ts",
+];
+
const nodeModuleIds = new Set([
"async_hooks",
"buffer",
@@ -110,6 +116,24 @@ function checkAgentModeImportBoundaries() {
}
}
+function checkContextCacheImportBoundaries() {
+ const desktopOnlyImports =
+ /import\s+(?:type\s+)?[^;]+?\s+from\s+["']@\/context\/(?:conversionsLocation|contextCacheFs)["']\s*;?/g;
+
+ for (const relativePath of contextCacheConsumerFiles) {
+ const source = readRepoFile(relativePath);
+ for (const match of source.matchAll(desktopOnlyImports)) {
+ const statement = match[0];
+ if (!isTypeOnlyImport(statement)) {
+ fail(
+ `${relativePath}: value import from ${statement.match(/["']([^"']+)["']/)?.[1]} ` +
+ "is on the mobile load path; use a desktop-gated dynamic import."
+ );
+ }
+ }
+ }
+}
+
function createCallableStub(name) {
function Stub() {}
Object.defineProperty(Stub, "name", { value: name.replace(/[^A-Za-z0-9_$]/g, "_") || "Stub" });
@@ -357,6 +381,7 @@ function runBundleEvaluationSmoke() {
}
checkAgentModeImportBoundaries();
+checkContextCacheImportBoundaries();
runBundleEvaluationSmoke();
if (failures.length > 0) {
diff --git a/src/agentMode/acp/AcpBackendProcess.test.ts b/src/agentMode/acp/AcpBackendProcess.test.ts
index a4acde1d..a90ada1c 100644
--- a/src/agentMode/acp/AcpBackendProcess.test.ts
+++ b/src/agentMode/acp/AcpBackendProcess.test.ts
@@ -10,6 +10,44 @@ jest.mock("@/logger", () => ({
logError: jest.fn(),
}));
+// Controllable ACP SDK mock (richer than the shared `__mocks__` stub): lets a
+// test set the `initialize` response (to advertise capabilities) and capture
+// the `newSession` request. `mock`-prefixed names satisfy ts-jest's jest.mock
+// hoisting rules.
+let mockInitializeResult: unknown = { protocolVersion: 1 };
+const mockNewSession = jest.fn(async (..._args: unknown[]) => ({ sessionId: "test-session" }));
+const mockResumeSession = jest.fn(async (..._args: unknown[]) => ({}));
+const mockLoadSession = jest.fn(async (..._args: unknown[]) => ({}));
+
+jest.mock("@agentclientprotocol/sdk", () => {
+ class RequestError extends Error {
+ code: number;
+ constructor(code: number, message?: string) {
+ super(message);
+ this.code = code;
+ this.name = "RequestError";
+ }
+ }
+ class ClientSideConnection {
+ _client: unknown;
+ constructor(toClient: (c: unknown) => unknown) {
+ this._client = toClient(this);
+ }
+ initialize = jest.fn(async () => mockInitializeResult);
+ newSession = (...args: unknown[]) => mockNewSession(...args);
+ resumeSession = (...args: unknown[]) => mockResumeSession(...args);
+ loadSession = (...args: unknown[]) => mockLoadSession(...args);
+ prompt = jest.fn(async () => ({ stopReason: "end_turn" }));
+ cancel = jest.fn(async () => undefined);
+ }
+ return {
+ RequestError,
+ ClientSideConnection,
+ ndJsonStream: jest.fn(() => ({})),
+ PROTOCOL_VERSION: 1,
+ };
+});
+
const exitListeners = new Set<() => void>();
let mockProcessIsRunning = true;
@@ -67,6 +105,13 @@ describe("AcpBackendProcess", () => {
beforeEach(() => {
exitListeners.clear();
mockProcessIsRunning = true;
+ mockInitializeResult = { protocolVersion: 1 };
+ mockNewSession.mockClear();
+ mockNewSession.mockResolvedValue({ sessionId: "test-session" });
+ mockResumeSession.mockClear();
+ mockResumeSession.mockResolvedValue({});
+ mockLoadSession.mockClear();
+ mockLoadSession.mockResolvedValue({});
});
it("routes session updates to the matching session handler and drops unknown ones", async () => {
@@ -101,6 +146,129 @@ describe("AcpBackendProcess", () => {
expect(handler).toHaveBeenCalledTimes(1);
});
+ it("scopes todowrite id tracking per session — a registered id does not bleed across sessions", async () => {
+ const backend = new AcpBackendProcess(
+ buildApp(),
+ buildStubBackend(),
+ "1.0.0",
+ buildStubDescriptor()
+ );
+ await backend.start();
+
+ const handlerA = jest.fn();
+ const handlerB = jest.fn();
+ backend.registerSessionHandler("sess-A", handlerA);
+ backend.registerSessionHandler("sess-B", handlerB);
+ const client = getVaultClient(backend);
+
+ // Session A registers "shared-id" as a todowrite call (synthesizes a plan).
+ await client.sessionUpdate({
+ sessionId: "sess-A",
+ update: {
+ sessionUpdate: "tool_call",
+ toolCallId: "shared-id",
+ title: "todowrite",
+ rawInput: { todos: [{ content: "a", status: "pending", priority: "high" }] },
+ },
+ } as unknown as Parameters[0]);
+ expect(handlerA.mock.calls.some(([e]) => e.update.sessionUpdate === "plan")).toBe(true);
+
+ // Session B sends a titleless update reusing the SAME id with a todos
+ // payload. With a process-wide Set this would masquerade as a plan; scoped
+ // per session, B's tracker is empty so no plan is synthesized.
+ handlerB.mockClear();
+ await client.sessionUpdate({
+ sessionId: "sess-B",
+ update: {
+ sessionUpdate: "tool_call_update",
+ toolCallId: "shared-id",
+ rawInput: { todos: [{ content: "leaked", status: "pending", priority: "low" }] },
+ },
+ } as unknown as Parameters[0]);
+ expect(handlerB.mock.calls.some(([e]) => e.update.sessionUpdate === "plan")).toBe(false);
+ });
+
+ it("keeps a session's todo tracker when re-registering the same sessionId (stale unsubscribe is a no-op)", async () => {
+ const backend = new AcpBackendProcess(
+ buildApp(),
+ buildStubBackend(),
+ "1.0.0",
+ buildStubDescriptor()
+ );
+ await backend.start();
+ const client = getVaultClient(backend);
+
+ // First handler registers "todo-1" as a todowrite call, then unsubscribes —
+ // but a SECOND handler for the same session is already registered, so the
+ // stale unsubscribe must not delete the live tracker.
+ const stale = backend.registerSessionHandler("sess-X", jest.fn());
+ await client.sessionUpdate({
+ sessionId: "sess-X",
+ update: {
+ sessionUpdate: "tool_call",
+ toolCallId: "todo-1",
+ title: "todowrite",
+ rawInput: { todos: [{ content: "a", status: "pending", priority: "high" }] },
+ },
+ } as unknown as Parameters[0]);
+
+ const fresh = jest.fn();
+ backend.registerSessionHandler("sess-X", fresh); // replaces the handler
+ stale(); // stale unsubscribe — must NOT drop sess-X's tracker
+
+ // A titleless follow-up for the registered id must still synthesize a plan,
+ // proving the tracker survived the stale unsubscribe.
+ await client.sessionUpdate({
+ sessionId: "sess-X",
+ update: {
+ sessionUpdate: "tool_call_update",
+ toolCallId: "todo-1",
+ rawInput: { todos: [{ content: "a", status: "in_progress", priority: "high" }] },
+ },
+ } as unknown as Parameters[0]);
+ expect(fresh.mock.calls.some(([e]) => e.update.sessionUpdate === "plan")).toBe(true);
+ });
+
+ it("drops todo trackers on subprocess exit so a restarted process starts clean", async () => {
+ const backend = new AcpBackendProcess(
+ buildApp(),
+ buildStubBackend(),
+ "1.0.0",
+ buildStubDescriptor()
+ );
+ await backend.start();
+ const client = getVaultClient(backend);
+
+ backend.registerSessionHandler("sess-E", jest.fn());
+ await client.sessionUpdate({
+ sessionId: "sess-E",
+ update: {
+ sessionUpdate: "tool_call",
+ toolCallId: "todo-e",
+ title: "todowrite",
+ rawInput: { todos: [{ content: "a", status: "pending", priority: "high" }] },
+ },
+ } as unknown as Parameters[0]);
+
+ // Subprocess exits, then the process is restarted and the same sessionId +
+ // todo id reappear. With the onExit cleanup the tracker is gone, so a
+ // titleless update is NOT mistaken for the old todowrite call.
+ for (const fn of exitListeners) fn();
+ await backend.start();
+ const client2 = getVaultClient(backend);
+ const handler = jest.fn();
+ backend.registerSessionHandler("sess-E", handler);
+ await client2.sessionUpdate({
+ sessionId: "sess-E",
+ update: {
+ sessionUpdate: "tool_call_update",
+ toolCallId: "todo-e",
+ rawInput: { todos: [{ content: "stale", status: "pending", priority: "low" }] },
+ },
+ } as unknown as Parameters[0]);
+ expect(handler.mock.calls.some(([e]) => e.update.sessionUpdate === "plan")).toBe(false);
+ });
+
it("returns cancelled outcome when permission is requested but no prompter is registered", async () => {
const backend = new AcpBackendProcess(
buildApp(),
@@ -179,4 +347,128 @@ describe("AcpBackendProcess", () => {
);
await expect(backend.prompt({ sessionId: "s1", prompt: [] })).rejects.toThrow(/start\(\)/);
});
+
+ describe("additionalDirectories (capability-gated)", () => {
+ async function startBackend(): Promise {
+ const backend = new AcpBackendProcess(
+ buildApp(),
+ buildStubBackend(),
+ "1.0.0",
+ buildStubDescriptor()
+ );
+ await backend.start();
+ return backend;
+ }
+
+ it("reflects the probed capability — false when the agent does not advertise it", async () => {
+ // codex 0.135 / opencode 1.2.27 shape: no additionalDirectories advertised.
+ mockInitializeResult = {
+ protocolVersion: 1,
+ agentCapabilities: { sessionCapabilities: { list: {}, close: {} } },
+ };
+ const backend = await startBackend();
+ expect(backend.supportsAdditionalDirectories()).toBe(false);
+ });
+
+ it("reflects the probed capability — true when the agent advertises it", async () => {
+ mockInitializeResult = {
+ protocolVersion: 1,
+ agentCapabilities: { sessionCapabilities: { additionalDirectories: {} } },
+ };
+ const backend = await startBackend();
+ expect(backend.supportsAdditionalDirectories()).toBe(true);
+ });
+
+ it("does NOT forward additionalDirectories at session/new when uncapable", async () => {
+ mockInitializeResult = { protocolVersion: 1 };
+ const backend = await startBackend();
+ await backend.newSession({
+ cwd: "/vault",
+ mcpServers: [],
+ additionalDirectories: ["/abs/context"],
+ });
+ const req = mockNewSession.mock.calls[0][0] as { additionalDirectories?: string[] };
+ expect(req).not.toHaveProperty("additionalDirectories");
+ });
+
+ it("forwards additionalDirectories at session/new only when capable", async () => {
+ mockInitializeResult = {
+ protocolVersion: 1,
+ agentCapabilities: { sessionCapabilities: { additionalDirectories: {} } },
+ };
+ const backend = await startBackend();
+ await backend.newSession({
+ cwd: "/vault",
+ mcpServers: [],
+ additionalDirectories: ["/abs/context-a", "/abs/context-b"],
+ });
+ const req = mockNewSession.mock.calls[0][0] as { additionalDirectories?: string[] };
+ expect(req.additionalDirectories).toEqual(["/abs/context-a", "/abs/context-b"]);
+ });
+
+ it("omits the field for a capable agent when no extra roots are supplied", async () => {
+ mockInitializeResult = {
+ protocolVersion: 1,
+ agentCapabilities: { sessionCapabilities: { additionalDirectories: {} } },
+ };
+ const backend = await startBackend();
+ await backend.newSession({ cwd: "/vault", mcpServers: [] });
+ const req = mockNewSession.mock.calls[0][0] as { additionalDirectories?: string[] };
+ expect(req).not.toHaveProperty("additionalDirectories");
+ });
+
+ // Resume/load re-establish the roots the same way session/new does, so a
+ // restored project chat must re-send them — symmetry the wire adapter owns.
+ it("forwards additionalDirectories at session/resume when capable", async () => {
+ mockInitializeResult = {
+ protocolVersion: 1,
+ agentCapabilities: { sessionCapabilities: { resume: {}, additionalDirectories: {} } },
+ };
+ const backend = await startBackend();
+ await backend.resumeSession({
+ sessionId: "s1",
+ cwd: "/vault",
+ mcpServers: [],
+ additionalDirectories: ["/abs/context-a", "/abs/context-b"],
+ });
+ const req = mockResumeSession.mock.calls[0][0] as { additionalDirectories?: string[] };
+ expect(req.additionalDirectories).toEqual(["/abs/context-a", "/abs/context-b"]);
+ });
+
+ it("does NOT forward additionalDirectories at session/resume when uncapable", async () => {
+ // resume advertised, additionalDirectories NOT — the gate must still hold.
+ mockInitializeResult = {
+ protocolVersion: 1,
+ agentCapabilities: { sessionCapabilities: { resume: {} } },
+ };
+ const backend = await startBackend();
+ await backend.resumeSession({
+ sessionId: "s1",
+ cwd: "/vault",
+ mcpServers: [],
+ additionalDirectories: ["/abs/context"],
+ });
+ const req = mockResumeSession.mock.calls[0][0] as { additionalDirectories?: string[] };
+ expect(req).not.toHaveProperty("additionalDirectories");
+ });
+
+ it("forwards additionalDirectories at session/load when capable", async () => {
+ mockInitializeResult = {
+ protocolVersion: 1,
+ agentCapabilities: {
+ loadSession: true,
+ sessionCapabilities: { additionalDirectories: {} },
+ },
+ };
+ const backend = await startBackend();
+ await backend.loadSession({
+ sessionId: "s1",
+ cwd: "/vault",
+ mcpServers: [],
+ additionalDirectories: ["/abs/context-a", "/abs/context-b"],
+ });
+ const req = mockLoadSession.mock.calls[0][0] as { additionalDirectories?: string[] };
+ expect(req.additionalDirectories).toEqual(["/abs/context-a", "/abs/context-b"]);
+ });
+ });
});
diff --git a/src/agentMode/acp/AcpBackendProcess.ts b/src/agentMode/acp/AcpBackendProcess.ts
index 194ffd38..b08ebdf9 100644
--- a/src/agentMode/acp/AcpBackendProcess.ts
+++ b/src/agentMode/acp/AcpBackendProcess.ts
@@ -4,6 +4,7 @@ import {
PROTOCOL_VERSION,
RequestError,
ndJsonStream,
+ type NewSessionRequest,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionConfigOption,
@@ -39,7 +40,7 @@ import type {
import { wrapStreamsForDebug } from "./debugTap";
import { AcpBackend } from "./types";
import {
- acpNotificationToEvent,
+ acpNotificationToEvents,
acpPermissionRequestToPrompt,
acpStateToBackendState,
cancelInputToAcp,
@@ -64,6 +65,7 @@ export type AcpCapability =
| "session/set_model"
| "session/set_mode"
| "session/set_config_option"
+ | "session/additional_directories"
| "mcp/http"
| "mcp/sse";
@@ -135,6 +137,14 @@ export class AcpBackendProcess implements BackendProcess {
private exitListeners = new Set<() => void>();
private capabilities = new Map();
private readonly sessionWireState = new Map();
+ // Tool-call ids first seen as a `todowrite`-titled call, so later
+ // tool_call_updates for the same id keep synthesizing a plan update even
+ // after opencode renames the title (e.g. "3 todos"). See wireTranslate's
+ // todoToolPlanFromAcp. Keyed by session like every other per-session map on
+ // this shared (per-backend) process — a single backend instance serves all
+ // its sessions, so a bare Set would leak ids across sessions and grow
+ // unbounded for the process lifetime. Pruned on session teardown + shutdown.
+ private readonly todoToolCallIdsBySession = new Map>();
constructor(
private readonly app: App,
@@ -175,6 +185,7 @@ export class AcpBackendProcess implements BackendProcess {
this.domainHandlers.clear();
this.pendingUpdates.clear();
this.sessionWireState.clear();
+ this.todoToolCallIdsBySession.clear();
this.permissionPrompter = null;
this.capabilities.clear();
for (const fn of this.exitListeners) {
@@ -219,8 +230,15 @@ export class AcpBackendProcess implements BackendProcess {
if (init.agentCapabilities?.mcpCapabilities?.sse === true) {
this.capabilities.set("mcp/sse", true);
}
+ // Experimental ACP capability: presence of the (possibly empty) object
+ // means the agent honors `additionalDirectories` on session lifecycle
+ // requests. codex 0.135 / opencode 1.2.27 don't advertise it, so they
+ // receive no field. Gating here auto-enables future versions that do.
+ if (init.agentCapabilities?.sessionCapabilities?.additionalDirectories != null) {
+ this.capabilities.set("session/additional_directories", true);
+ }
logInfo(
- `[AgentMode] initialized backend ${this.backend.id} (negotiated protocol v${init.protocolVersion}, listSessions=${this.hasCapability("session/list")}, resumeSession=${this.hasCapability("session/resume")}, loadSession=${this.hasCapability("session/load")}, mcp.http=${this.hasCapability("mcp/http")}, mcp.sse=${this.hasCapability("mcp/sse")})`
+ `[AgentMode] initialized backend ${this.backend.id} (negotiated protocol v${init.protocolVersion}, listSessions=${this.hasCapability("session/list")}, resumeSession=${this.hasCapability("session/resume")}, loadSession=${this.hasCapability("session/load")}, mcp.http=${this.hasCapability("mcp/http")}, mcp.sse=${this.hasCapability("mcp/sse")}, additionalDirectories=${this.hasCapability("session/additional_directories")})`
);
} catch (err) {
logError(
@@ -258,24 +276,33 @@ export class AcpBackendProcess implements BackendProcess {
this.pendingUpdates.delete(sessionId);
for (const wire of buffered) {
try {
- handler(acpNotificationToEvent(wire));
+ for (const event of acpNotificationToEvents(wire, this.todoToolCallIdsFor(sessionId)))
+ handler(event);
} catch (e) {
logWarn(`[AgentMode] replay of buffered session/update threw for ${sessionId}`, e);
}
}
}
return () => {
+ // Only tear down if THIS handler is still the registered one — a later
+ // re-register for the same sessionId (resume/reconnect) must not have its
+ // live tracker deleted by the stale unsubscribe.
if (this.domainHandlers.get(sessionId) === handler) {
this.domainHandlers.delete(sessionId);
+ // Teardown (not per-turn): the handler is unregistered only when the
+ // AgentSession disposes, so drop this session's todo-id tracker too.
+ this.todoToolCallIdsBySession.delete(sessionId);
}
};
}
async newSession(params: OpenSessionInput): Promise {
- const wireResp = await this.requireConnection().newSession({
+ const req: NewSessionRequest = {
cwd: params.cwd,
mcpServers: params.mcpServers.map(mcpServerSpecToAcp),
- });
+ ...this.additionalDirectoriesField(params.additionalDirectories),
+ };
+ const wireResp = await this.requireConnection().newSession(req);
this.recordWireState(wireResp.sessionId, {
models: wireResp.models ?? null,
modes: wireResp.modes ?? null,
@@ -307,6 +334,25 @@ export class AcpBackendProcess implements BackendProcess {
return this.hasCapability(transport === "http" ? "mcp/http" : "mcp/sse");
}
+ supportsAdditionalDirectories(): boolean {
+ return this.hasCapability("session/additional_directories");
+ }
+
+ // Extra searchable roots ride on every session-lifecycle request (new, resume,
+ // load), but only when the agent advertises the experimental
+ // `additionalDirectories` capability — resume/load re-establish the roots just
+ // like `session/new`, so they must carry them too or a restored project chat
+ // loses its off-vault context roots. Agents that don't advertise the capability
+ // get no field at all; sending one they'll silently ignore would be misleading.
+ // Empty/absent roots also send nothing, so non-project sessions stay untouched.
+ private additionalDirectoriesField(roots: string[] | undefined): {
+ additionalDirectories?: string[];
+ } {
+ return this.supportsAdditionalDirectories() && roots?.length
+ ? { additionalDirectories: roots }
+ : {};
+ }
+
async setSessionModel(params: { sessionId: SessionId; modelId: string }): Promise {
await this.dispatchCapability("session/set_model", (c) =>
c.unstable_setSessionModel({
@@ -433,6 +479,7 @@ export class AcpBackendProcess implements BackendProcess {
sessionId: sessionIdToAcp(params.sessionId),
cwd: params.cwd,
mcpServers: params.mcpServers.map(mcpServerSpecToAcp),
+ ...this.additionalDirectoriesField(params.additionalDirectories),
}),
{ mustBeAdvertised: true }
);
@@ -455,6 +502,7 @@ export class AcpBackendProcess implements BackendProcess {
sessionId: sessionIdToAcp(params.sessionId),
cwd: params.cwd,
mcpServers: params.mcpServers.map(mcpServerSpecToAcp),
+ ...this.additionalDirectoriesField(params.additionalDirectories),
}),
{ mustBeAdvertised: true }
);
@@ -474,6 +522,7 @@ export class AcpBackendProcess implements BackendProcess {
this.domainHandlers.clear();
this.pendingUpdates.clear();
this.sessionWireState.clear();
+ this.todoToolCallIdsBySession.clear();
this.permissionPrompter = null;
this.capabilities.clear();
if (this.process) {
@@ -501,6 +550,20 @@ export class AcpBackendProcess implements BackendProcess {
this.sessionWireState.set(sessionIdFromAcp(sessionId), wire);
}
+ /**
+ * The todo-tool id tracker for one session, created on first use. Scoping it
+ * per session keeps one session's `todowrite` ids from being honored for
+ * another on this shared backend process (see the field's declaration).
+ */
+ private todoToolCallIdsFor(sessionId: SessionId): Set {
+ let ids = this.todoToolCallIdsBySession.get(sessionId);
+ if (!ids) {
+ ids = new Set();
+ this.todoToolCallIdsBySession.set(sessionId, ids);
+ }
+ return ids;
+ }
+
private computeState(sessionId: AcpSessionId): BackendState {
const wire = this.sessionWireState.get(sessionIdFromAcp(sessionId)) ?? {
models: null,
@@ -555,7 +618,8 @@ export class AcpBackendProcess implements BackendProcess {
return;
}
- handler(acpNotificationToEvent(update));
+ for (const event of acpNotificationToEvents(update, this.todoToolCallIdsFor(sessionId)))
+ handler(event);
}
private async handlePermission(
diff --git a/src/agentMode/acp/wireTranslate.test.ts b/src/agentMode/acp/wireTranslate.test.ts
new file mode 100644
index 00000000..ff38a027
--- /dev/null
+++ b/src/agentMode/acp/wireTranslate.test.ts
@@ -0,0 +1,184 @@
+import type { SessionNotification } from "@agentclientprotocol/sdk";
+import { acpNotificationToEvents } from "./wireTranslate";
+
+const SESSION_ID = "sess-1";
+
+function notification(update: Record): SessionNotification {
+ return { sessionId: SESSION_ID, update } as unknown as SessionNotification;
+}
+
+const VALID_TODOS = [
+ { content: "Brainstorm autumn imagery", status: "in_progress", priority: "high" },
+ { content: "Draft the haiku", status: "pending", priority: "high" },
+ { content: "Review and polish", status: "pending", priority: "medium" },
+];
+
+describe("acpNotificationToEvents — todowrite → synthesized plan", () => {
+ it("appends a plan event after a todowrite tool_call carrying rawInput.todos", () => {
+ const events = acpNotificationToEvents(
+ notification({
+ sessionUpdate: "tool_call",
+ toolCallId: "call-1",
+ title: "todowrite",
+ kind: "other",
+ status: "in_progress",
+ rawInput: { todos: VALID_TODOS },
+ })
+ );
+ expect(events).toHaveLength(2);
+ expect(events[0].update.sessionUpdate).toBe("tool_call");
+ expect(events[1].update).toEqual({
+ sessionUpdate: "plan",
+ entries: [
+ { content: "Brainstorm autumn imagery", status: "in_progress", priority: "high" },
+ { content: "Draft the haiku", status: "pending", priority: "high" },
+ { content: "Review and polish", status: "pending", priority: "medium" },
+ ],
+ });
+ expect(events.every((e) => e.sessionId === SESSION_ID)).toBe(true);
+ });
+
+ it("accepts a titleless tool_call_update once its id was registered as todowrite", () => {
+ const ids = new Set();
+ acpNotificationToEvents(
+ notification({
+ sessionUpdate: "tool_call",
+ toolCallId: "call-1",
+ title: "todowrite",
+ rawInput: { todos: VALID_TODOS },
+ }),
+ ids
+ );
+ const events = acpNotificationToEvents(
+ notification({
+ sessionUpdate: "tool_call_update",
+ toolCallId: "call-1",
+ rawInput: { todos: VALID_TODOS },
+ }),
+ ids
+ );
+ expect(events).toHaveLength(2);
+ expect(events[1].update.sessionUpdate).toBe("plan");
+ });
+
+ it("rejects updates whose present title is not the native todowrite tool", () => {
+ // Without a tracker, a renamed/foreign title is skipped (its predecessor
+ // already delivered the same list).
+ for (const title of ["3 todos", "bash", "mcp__tracker__todowrite"]) {
+ const events = acpNotificationToEvents(
+ notification({
+ sessionUpdate: "tool_call_update",
+ toolCallId: "call-1",
+ title,
+ rawInput: { todos: VALID_TODOS },
+ })
+ );
+ expect(events).toHaveLength(1);
+ }
+ });
+
+ it("continues synthesizing for a renamed/titleless update once the id is registered", () => {
+ const ids = new Set();
+ // First sight: titled `todowrite` registers the id and synthesizes.
+ const first = acpNotificationToEvents(
+ notification({
+ sessionUpdate: "tool_call",
+ toolCallId: "call-7",
+ title: "todowrite",
+ rawInput: { todos: VALID_TODOS },
+ }),
+ ids
+ );
+ expect(first).toHaveLength(2);
+ // Follow-up renamed "3 todos" for the SAME id still synthesizes.
+ const renamed = acpNotificationToEvents(
+ notification({
+ sessionUpdate: "tool_call_update",
+ toolCallId: "call-7",
+ title: "3 todos",
+ rawInput: { todos: [{ content: "Brainstorm autumn imagery", status: "completed" }] },
+ }),
+ ids
+ );
+ expect(renamed).toHaveLength(2);
+ expect(renamed[1].update).toEqual({
+ sessionUpdate: "plan",
+ entries: [{ content: "Brainstorm autumn imagery", status: "completed", priority: "medium" }],
+ });
+ });
+
+ it("does not synthesize for an unregistered id carrying a todos-shaped payload", () => {
+ const ids = new Set();
+ // A foreign tool's titleless update with a todos field must not masquerade.
+ const events = acpNotificationToEvents(
+ notification({
+ sessionUpdate: "tool_call_update",
+ toolCallId: "other-tool",
+ rawInput: { todos: VALID_TODOS },
+ }),
+ ids
+ );
+ expect(events).toHaveLength(1);
+ });
+
+ it("ignores malformed todos and never appends for non-tool updates", () => {
+ expect(
+ acpNotificationToEvents(
+ notification({
+ sessionUpdate: "tool_call",
+ toolCallId: "call-1",
+ title: "todowrite",
+ rawInput: { todos: [{ content: "", status: "pending" }, { status: "in_progress" }, "x"] },
+ })
+ )
+ ).toHaveLength(1);
+ expect(
+ acpNotificationToEvents(
+ notification({
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: "hello" },
+ })
+ )
+ ).toHaveLength(1);
+ });
+
+ it("emits an empty plan when a registered todo call reports todos: [] (a clear)", () => {
+ const ids = new Set();
+ // First the list arrives, then the agent clears it with an empty array —
+ // the synth must emit an empty plan so the snapshot resets downstream.
+ acpNotificationToEvents(
+ notification({
+ sessionUpdate: "tool_call",
+ toolCallId: "call-9",
+ title: "todowrite",
+ rawInput: { todos: VALID_TODOS },
+ }),
+ ids
+ );
+ const cleared = acpNotificationToEvents(
+ notification({
+ sessionUpdate: "tool_call_update",
+ toolCallId: "call-9",
+ title: "0 todos",
+ rawInput: { todos: [] },
+ }),
+ ids
+ );
+ expect(cleared).toHaveLength(2);
+ expect(cleared[1].update).toEqual({ sessionUpdate: "plan", entries: [] });
+ });
+
+ it("passes a real plan notification through unchanged as a single event", () => {
+ const events = acpNotificationToEvents(
+ notification({
+ sessionUpdate: "plan",
+ entries: [{ content: "step", status: "pending", priority: "medium" }],
+ })
+ );
+ expect(events).toHaveLength(1);
+ expect(events[0].update).toEqual({
+ sessionUpdate: "plan",
+ entries: [{ content: "step", status: "pending", priority: "medium" }],
+ });
+ });
+});
diff --git a/src/agentMode/acp/wireTranslate.ts b/src/agentMode/acp/wireTranslate.ts
index 0c608a82..94157fc2 100644
--- a/src/agentMode/acp/wireTranslate.ts
+++ b/src/agentMode/acp/wireTranslate.ts
@@ -27,6 +27,7 @@ import type {
ToolKind as AcpToolKind,
} from "@agentclientprotocol/sdk";
import type {
+ AgentPlanEntry,
AgentToolKind,
AgentToolStatus,
BackendConfigOption,
@@ -266,11 +267,96 @@ function toolCallDeltaFromAcp(
// ---- Notification → SessionEvent --------------------------------------
-export function acpNotificationToEvent(n: SessionNotification): SessionEvent {
- return {
- sessionId: sessionIdFromAcp(n.sessionId),
- update: acpUpdateToSessionUpdate(n.update),
- };
+/**
+ * One wire notification can yield more than one session event: a `todowrite`
+ * tool call additionally synthesizes the standard `plan` update (see
+ * {@link todoToolPlanFromAcp}), so the trail's PlanPill and the todo snapshot
+ * stay backend-agnostic. The base translation always comes first.
+ *
+ * `todoToolCallIds` is one session's id set, owned by the caller
+ * (AcpBackendProcess keys it per session — see `todoToolCallIdsFor`): the first
+ * `todowrite`-titled tool call registers its id, so later `tool_call_update`s
+ * for the same call still synthesize even after opencode renames the title
+ * (e.g. "3 todos") or drops it. Omit it (tests, replay) to fall back to
+ * title-only recognition.
+ */
+export function acpNotificationToEvents(
+ n: SessionNotification,
+ todoToolCallIds?: Set
+): SessionEvent[] {
+ const sessionId = sessionIdFromAcp(n.sessionId);
+ const events: SessionEvent[] = [{ sessionId, update: acpUpdateToSessionUpdate(n.update) }];
+ const todoPlan = todoToolPlanFromAcp(n.update, todoToolCallIds);
+ if (todoPlan) events.push({ sessionId, update: todoPlan });
+ return events;
+}
+
+/**
+ * opencode reports its execution todo list as a generic `todowrite` tool call
+ * whose `rawInput.todos` carries the full list — current releases (1.17.3)
+ * have NO plan-channel emission at all (verified: binary-string audit + live
+ * probes, designdocs/agent-projects/verify/README.md "Task-list channel").
+ * Synthesize the standard `plan` update from it. Builds that DO emit a real
+ * plan update coexist fine: identical entries dedupe downstream
+ * (`planEntriesEqual` for the message part, signature compare for the
+ * snapshot).
+ *
+ * Tool identity: the initial `tool_call` titles itself `todowrite`; opencode
+ * then mutates follow-up update titles (e.g. "3 todos"). We register the
+ * call's id on first sight (title === todowrite) so subsequent updates for the
+ * same id keep synthesizing regardless of title — and unknown ids are ignored,
+ * so a stray `{todos}` payload from another tool/backend can't masquerade.
+ */
+function todoToolPlanFromAcp(
+ update: SessionNotification["update"],
+ todoToolCallIds?: Set
+): SessionUpdate | null {
+ if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
+ return null;
+ }
+ const toolCallId = (update as { toolCallId?: string }).toolCallId;
+ const title = (update as { title?: string | null }).title;
+ const isTodoTitle =
+ title != null &&
+ (() => {
+ const { tool, mcpServer } = resolveToolName(title);
+ return !mcpServer && tool.toLowerCase() === "todowrite";
+ })();
+
+ if (isTodoTitle && toolCallId) todoToolCallIds?.add(toolCallId);
+
+ // Recognized when the title says todowrite, or the id was registered from an
+ // earlier todowrite-titled call. Without a tracker (tests/replay), fall back
+ // to title-only — a renamed follow-up is then skipped, but its predecessor
+ // already delivered the same list.
+ const recognized =
+ isTodoTitle || (toolCallId != null && todoToolCallIds?.has(toolCallId) === true);
+ if (!recognized) return null;
+
+ const todos = (update.rawInput as { todos?: unknown } | undefined)?.todos;
+ if (!Array.isArray(todos)) return null;
+ const entries: AgentPlanEntry[] = [];
+ for (const todo of todos) {
+ if (typeof todo !== "object" || todo === null) continue;
+ const t = todo as { content?: unknown; status?: unknown; priority?: unknown };
+ if (typeof t.content !== "string" || t.content.length === 0) continue;
+ if (t.status !== "pending" && t.status !== "in_progress" && t.status !== "completed") continue;
+ entries.push({
+ content: t.content,
+ status: t.status,
+ priority:
+ t.priority === "high" || t.priority === "medium" || t.priority === "low"
+ ? t.priority
+ : "medium",
+ });
+ }
+ // A recognized todo tool reporting `todos: []` is a genuine clear — emit an
+ // empty plan so the snapshot resets (matching the claude path, which emits an
+ // empty plan when its last task is removed). But a NON-empty array that
+ // filtered down to nothing is malformed input, not a clear: don't wipe a good
+ // list on garbage. (`todos` is already array-guarded above.)
+ if (entries.length === 0 && todos.length > 0) return null;
+ return { sessionUpdate: "plan", entries };
}
function acpUpdateToSessionUpdate(update: SessionNotification["update"]): SessionUpdate {
diff --git a/src/agentMode/backends/claude/descriptor.ts b/src/agentMode/backends/claude/descriptor.ts
index a00ba278..09f79103 100644
--- a/src/agentMode/backends/claude/descriptor.ts
+++ b/src/agentMode/backends/claude/descriptor.ts
@@ -248,12 +248,17 @@ export const ClaudeBackendDescriptor: BackendDescriptor = {
(await getClaudeAuthStatus(claudePath, claudeChildEnv(getSettings()))).loggedIn,
isPlanModePlanFilePath: isClaudePlanModePlanFilePath,
getDefaultModelId: () => getSettings().agentMode?.backends?.claude?.defaultModel?.baseModelId,
- // Forward the shared composed system prompt — the Copilot base framing
- // (unless the user disabled it), the pill-syntax directive, and the
- // user's custom prompt. The SDK appends it to its `claude_code` preset
- // (see `ClaudeSdkBackendProcess`), so Claude keeps its tool/planning
- // framing while gaining the Obsidian-vault identity. Re-read per
- // `newSession()`, so a prompt change applies to the next session.
+ // Compose the shared system prompt — the Copilot base framing (unless the
+ // user disabled it), the pill-syntax directive, and the user's custom
+ // prompt — plus the owning project's instructions when the session is
+ // project-scoped. The SDK resolves the project instructions per session
+ // (via the manager-injected profile provider) and passes the opaque body
+ // here; `backends/shared` never imports the `projects/` layer. The
+ // result appends to Claude's `claude_code` preset (see
+ // `ClaudeSdkBackendProcess`), so Claude keeps its tool/planning framing
+ // while gaining the Obsidian-vault identity. Re-read per `newSession()`,
+ // so a prompt change applies to the next session. A global (no-project)
+ // session passes `undefined`, yielding the byte-identical global prompt.
//
// Claude discovers skills natively from `.claude/skills/`, so the payload
// carries no SKILL.md authoring instructions. Claude has no
@@ -261,7 +266,7 @@ export const ClaudeBackendDescriptor: BackendDescriptor = {
// symlink fanout already enforces visibility (no link = not seen). If the
// Claude Agent SDK ever grows a per-skill deny hook, wire
// `composeDenyList(getManagedSkills(), "claude")` in here.
- getSystemPromptAppend: () => buildAgentSystemPrompt(),
+ getSystemPromptAppend: (opts) => buildAgentSystemPrompt(opts),
});
},
diff --git a/src/agentMode/backends/codex/CodexBackend.test.ts b/src/agentMode/backends/codex/CodexBackend.test.ts
index e5f5b2b9..fb513081 100644
--- a/src/agentMode/backends/codex/CodexBackend.test.ts
+++ b/src/agentMode/backends/codex/CodexBackend.test.ts
@@ -53,6 +53,7 @@ describe("CodexBackend.buildSpawnDescriptor", () => {
mcpServers: [],
activeBackend: "codex",
debugFullFrames: false,
+ welcomeDismissed: false,
skills: { folder: "copilot/skills" },
backends: {
codex: { binaryPath: "/usr/local/bin/codex-acp" },
@@ -114,6 +115,7 @@ describe("CodexBackend.buildSpawnDescriptor", () => {
mcpServers: [],
activeBackend: "codex",
debugFullFrames: false,
+ welcomeDismissed: false,
skills: { folder: "team-skills" },
backends: { codex: { binaryPath: "/usr/local/bin/codex-acp" } },
},
@@ -179,6 +181,15 @@ describe("CodexBackend.buildSpawnDescriptor", () => {
);
});
+ it("does not add a project.md fallback to the codex spawn args", async () => {
+ // Session-start ensureAgentsMirror supersedes the spawn-level fallback for project scopes;
+ // omitting it also prevents a GLOBAL session from treating a vault-root project.md note as
+ // codex instructions (the spawn descriptor has no scope to gate on).
+ const backend = new CodexBackend();
+ const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" });
+ expect(desc.args).not.toContainEqual(expect.stringContaining("project_doc_fallback_filenames"));
+ });
+
it("throws when the codex binary path is unset", async () => {
setSettings({
agentMode: {
@@ -186,6 +197,7 @@ describe("CodexBackend.buildSpawnDescriptor", () => {
mcpServers: [],
activeBackend: "codex",
debugFullFrames: false,
+ welcomeDismissed: false,
skills: { folder: "copilot/skills" },
backends: {},
},
diff --git a/src/agentMode/backends/codex/CodexBackend.ts b/src/agentMode/backends/codex/CodexBackend.ts
index 55656a2d..5610b252 100644
--- a/src/agentMode/backends/codex/CodexBackend.ts
+++ b/src/agentMode/backends/codex/CodexBackend.ts
@@ -51,6 +51,14 @@ export class CodexBackend implements AcpBackend {
"-c",
'sandbox_mode="workspace-write"',
];
+ // DESIGN NOTE: deliberately no `project_doc_fallback_filenames=["project.md"]`.
+ // Post-Phase-2 the session-start `ensureAgentsMirror` (AgentSessionManager, run before
+ // `resolveSessionCwd` for codex/opencode project sessions) guarantees the marker'd
+ // `AGENTS.md` mirror exists in the project cwd, so a `project.md` fallback is redundant.
+ // This descriptor only knows `vaultBasePath`, not the session scope: a spawn-level fallback
+ // would also apply to GLOBAL sessions and let codex read a user's vault-root `project.md`
+ // note as instructions. On the rare ensure failure a project session gets no instructions
+ // (ensure never throws and re-runs next session) rather than the frontmatter-laden source.
return descriptor;
}
}
diff --git a/src/agentMode/backends/opencode/OpencodeBackend.test.ts b/src/agentMode/backends/opencode/OpencodeBackend.test.ts
index d3ed69b2..8ba10066 100644
--- a/src/agentMode/backends/opencode/OpencodeBackend.test.ts
+++ b/src/agentMode/backends/opencode/OpencodeBackend.test.ts
@@ -1,4 +1,5 @@
import { ChatModelProviders } from "@/constants";
+import { logWarn } from "@/logger";
import { getSettings, resetSettings, setSettings, updateSetting } from "@/settings/model";
import type {
BackendConfigRegistry,
@@ -555,6 +556,7 @@ describe("buildOpencodeConfig — agent/prompt/mode/skills blocks (preserved)",
mcpServers: [],
activeBackend: "opencode",
debugFullFrames: false,
+ welcomeDismissed: false,
skills: { folder: "copilot/skills" },
backends: {
opencode: {
@@ -575,6 +577,7 @@ describe("buildOpencodeConfig — agent/prompt/mode/skills blocks (preserved)",
mcpServers: [],
activeBackend: "opencode",
debugFullFrames: false,
+ welcomeDismissed: false,
skills: { folder: "copilot/skills" },
backends: {
opencode: {
@@ -595,6 +598,7 @@ describe("buildOpencodeConfig — agent/prompt/mode/skills blocks (preserved)",
mcpServers: [],
activeBackend: "opencode",
debugFullFrames: false,
+ welcomeDismissed: false,
skills: { folder: "copilot/skills" },
backends: {
opencode: { binaryPath: "/x" },
@@ -670,6 +674,7 @@ describe("buildOpencodeConfig — agent/prompt/mode/skills blocks (preserved)",
mcpServers: [],
activeBackend: "opencode",
debugFullFrames: false,
+ welcomeDismissed: false,
skills: { folder: "team-skills" },
backends: {},
},
@@ -740,11 +745,51 @@ describe("buildOpencodeConfig — agent/prompt/mode/skills blocks (preserved)",
});
});
+describe("buildOpencodeConfig — context-cache external_directory allow", () => {
+ beforeEach(() => {
+ resetSettings();
+ seedSkills([]);
+ resetPromptState();
+ });
+
+ type AgentPerm = {
+ agent: Record }>;
+ };
+
+ it("injects a cacheRoot-scoped external_directory allow on both spawn agents", async () => {
+ const cfg = (await buildOpencodeConfig(
+ getSettings(),
+ NO_MODELS_DEPS,
+ "/home/u/.obsidian-copilot/vaults/abc123/context-cache"
+ )) as AgentPerm;
+ const allow = {
+ external_directory: {
+ "/home/u/.obsidian-copilot/vaults/abc123/context-cache/**": "allow",
+ },
+ };
+ // build (auto) gets only the external_directory grant — no bash/edit asks.
+ expect(cfg.agent.build.permission).toEqual(allow);
+ // copilot-build (default) keeps its ask-before-write perms AND gains allow.
+ expect(cfg.agent["copilot-build"].permission).toEqual({
+ bash: "ask",
+ edit: "ask",
+ ...allow,
+ });
+ });
+
+ it("injects nothing when no cacheRoot is provided (feature dormant)", async () => {
+ const cfg = (await buildOpencodeConfig(getSettings(), NO_MODELS_DEPS)) as AgentPerm;
+ expect(cfg.agent.build.permission).toBeUndefined();
+ expect(cfg.agent["copilot-build"].permission).toEqual({ bash: "ask", edit: "ask" });
+ });
+});
+
describe("OpencodeBackend.buildSpawnDescriptor", () => {
beforeEach(() => {
resetSettings();
seedSkills([]);
resetPromptState();
+ (logWarn as jest.Mock).mockClear();
});
it("throws if no binary is installed", async () => {
@@ -760,6 +805,7 @@ describe("OpencodeBackend.buildSpawnDescriptor", () => {
mcpServers: [],
activeBackend: "opencode",
debugFullFrames: false,
+ welcomeDismissed: false,
skills: { folder: "copilot/skills" },
backends: {
opencode: {
@@ -786,6 +832,102 @@ describe("OpencodeBackend.buildSpawnDescriptor", () => {
expect(cfg.provider.anthropic.options).toEqual({ apiKey: "anth-xyz" });
expect(cfg.provider.anthropic.models).toEqual({ "claude-sonnet-4-6": {} });
});
+
+ it("threads the injected getCacheRoot into the spawned external_directory allow", async () => {
+ updateSetting("agentMode", {
+ byok: {},
+ mcpServers: [],
+ activeBackend: "opencode",
+ debugFullFrames: false,
+ welcomeDismissed: false,
+ skills: { folder: "copilot/skills" },
+ backends: { opencode: { binaryPath: "/path/to/opencode" } },
+ });
+ const deps: OpencodeModelDeps = {
+ ...NO_MODELS_DEPS,
+ getCacheRoot: () => "/cache/root",
+ };
+ const backend = new OpencodeBackend(deps);
+ const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault/abs" });
+ const cfg = JSON.parse(desc.env.OPENCODE_CONFIG_CONTENT as string);
+ expect(cfg.agent.build.permission).toEqual({
+ external_directory: { "/cache/root/**": "allow" },
+ });
+ expect(cfg.agent["copilot-build"].permission).toEqual({
+ bash: "ask",
+ edit: "ask",
+ external_directory: { "/cache/root/**": "allow" },
+ });
+ expect(logWarn).not.toHaveBeenCalled();
+ });
+
+ it("warns when an OPENCODE_CONFIG_CONTENT override would drop the cache allow rule", async () => {
+ updateSetting("agentMode", {
+ byok: {},
+ mcpServers: [],
+ activeBackend: "opencode",
+ debugFullFrames: false,
+ welcomeDismissed: false,
+ skills: { folder: "copilot/skills" },
+ backends: {
+ opencode: {
+ binaryPath: "/path/to/opencode",
+ envOverrides: { OPENCODE_CONFIG_CONTENT: "{}" },
+ },
+ },
+ });
+ const deps: OpencodeModelDeps = {
+ ...NO_MODELS_DEPS,
+ getCacheRoot: () => "/cache/root",
+ };
+ const backend = new OpencodeBackend(deps);
+ await backend.buildSpawnDescriptor({ vaultBasePath: "/vault/abs" });
+ expect(logWarn).toHaveBeenCalledWith(
+ expect.stringContaining("external_directory allow rule is dropped")
+ );
+ });
+
+ it("treats a blank getCacheRoot result as unavailable (no allow rule, no warn)", async () => {
+ updateSetting("agentMode", {
+ byok: {},
+ mcpServers: [],
+ activeBackend: "opencode",
+ debugFullFrames: false,
+ welcomeDismissed: false,
+ skills: { folder: "copilot/skills" },
+ backends: { opencode: { binaryPath: "/path/to/opencode" } },
+ });
+ const deps: OpencodeModelDeps = {
+ ...NO_MODELS_DEPS,
+ getCacheRoot: () => " ",
+ };
+ const backend = new OpencodeBackend(deps);
+ const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault/abs" });
+ const cfg = JSON.parse(desc.env.OPENCODE_CONFIG_CONTENT as string);
+ expect(cfg.agent.build.permission).toBeUndefined();
+ expect(cfg.agent["copilot-build"].permission).toEqual({ bash: "ask", edit: "ask" });
+ expect(logWarn).not.toHaveBeenCalled();
+ });
+
+ it("does not warn about the override when no cacheRoot is resolved", async () => {
+ updateSetting("agentMode", {
+ byok: {},
+ mcpServers: [],
+ activeBackend: "opencode",
+ debugFullFrames: false,
+ welcomeDismissed: false,
+ skills: { folder: "copilot/skills" },
+ backends: {
+ opencode: {
+ binaryPath: "/path/to/opencode",
+ envOverrides: { OPENCODE_CONFIG_CONTENT: "{}" },
+ },
+ },
+ });
+ const backend = new OpencodeBackend(NO_MODELS_DEPS);
+ await backend.buildSpawnDescriptor({ vaultBasePath: "/vault/abs" });
+ expect(logWarn).not.toHaveBeenCalled();
+ });
});
// `COPILOT_PROMPT_BASE` and the full `buildAgentSystemPrompt` composition are
diff --git a/src/agentMode/backends/opencode/OpencodeBackend.ts b/src/agentMode/backends/opencode/OpencodeBackend.ts
index 9360d8b7..a220dbb2 100644
--- a/src/agentMode/backends/opencode/OpencodeBackend.ts
+++ b/src/agentMode/backends/opencode/OpencodeBackend.ts
@@ -1,5 +1,5 @@
import { ChatModelProviders } from "@/constants";
-import { logInfo } from "@/logger";
+import { logInfo, logWarn } from "@/logger";
import { getSettings } from "@/settings/model";
import type { CopilotSettings } from "@/settings/model";
import { isSelfHostedProvider } from "@/modelManagement";
@@ -57,6 +57,14 @@ export const OPENCODE_CANONICAL_MODE_AGENT_IDS: Partial string | undefined;
}
/**
@@ -75,15 +83,47 @@ export class OpencodeBackend implements AcpBackend {
}
async buildSpawnDescriptor(ctx: { vaultBasePath: string }): Promise {
- const binaryPath = getSettings().agentMode?.backends?.opencode?.binaryPath;
+ const settings = getSettings();
+ const binaryPath = settings.agentMode?.backends?.opencode?.binaryPath;
if (!binaryPath) {
throw new Error(
"opencode binary not installed. Open Agent Mode settings and install it before starting a session."
);
}
- const config = await buildOpencodeConfig(getSettings(), this.#deps);
- const envOverrides = getSettings().agentMode?.backends?.opencode?.envOverrides ?? {};
+ // DESIGN NOTE: opencode only auto-discovers `AGENTS.md` from the session cwd and has no
+ // `project_doc_fallback_filenames` equivalent. The plugin guarantees the file exists by
+ // materializing the generated `AGENTS.md` mirror from the project's `project.md` at session
+ // start (see `ensureAgentsMirror`, called before cwd resolution in AgentSessionManager) —
+ // the same session-start ensure codex now relies on as its sole guarantee (codex's
+ // `project.md` fallback was removed; see the matching note in CodexBackend). Hence opencode
+ // needs no instruction-specific code in this spawn.
+ // The off-vault conversions cache lives outside opencode's `--cwd `
+ // boundary, so opencode prompts (`external_directory` ask) on every snapshot
+ // read unless we pre-allow it (see `buildOpencodeConfig`). cacheRoot is a
+ // static path with no first-launch window, so resolving it here at spawn is
+ // unconditional. The resolver is injected (from `conversionsLocation`) so
+ // this backend never derives the vault path itself.
+ // Normalize before use: the allow rule is a security boundary, so a blank /
+ // whitespace-only resolver result is treated as "unavailable" explicitly
+ // rather than leaning on downstream truthiness.
+ const cacheRoot = normalizeCacheRoot(this.#deps.getCacheRoot?.());
+ const config = await buildOpencodeConfig(settings, this.#deps, cacheRoot);
+ const envOverrides = settings.agentMode?.backends?.opencode?.envOverrides ?? {};
+ // Accepted degradation: a user `OPENCODE_CONFIG_CONTENT` override (spread
+ // last below) replaces the whole generated config, dropping the allow rule.
+ // opencode then prompts on every snapshot read; the sources still appear in
+ // the manifest. Warn so the lost approval-suppression is diagnosable.
+ if (
+ cacheRoot &&
+ Object.prototype.hasOwnProperty.call(envOverrides, "OPENCODE_CONFIG_CONTENT")
+ ) {
+ logWarn(
+ "[AgentMode] opencode envOverrides.OPENCODE_CONFIG_CONTENT replaces the generated config; " +
+ "the context-cache external_directory allow rule is dropped — opencode will prompt on every " +
+ "snapshot read. Remove that override to restore silent cache access."
+ );
+ }
// Builtin Copilot Plus skill scripts read the license from the env.
const plusEnv = await buildCopilotPlusEnv();
@@ -102,6 +142,18 @@ export class OpencodeBackend implements AcpBackend {
}
}
+/**
+ * Trim the injected cache root to a non-empty path, or `undefined`. The resolver
+ * contract (`conversionsLocation.cacheRoot`) is to return an ABSOLUTE
+ * context-cache root; absoluteness is the resolver's guarantee, but we refuse to
+ * emit an `external_directory` grant for a blank value so the security boundary
+ * never depends on bare truthiness of an empty string.
+ */
+function normalizeCacheRoot(raw: string | undefined): string | undefined {
+ const trimmed = raw?.trim();
+ return trimmed ? trimmed : undefined;
+}
+
/** Mutable opencode provider config entry built into `OPENCODE_CONFIG_CONTENT`. */
type ProviderConfig = {
npm?: string;
@@ -122,7 +174,8 @@ type ProviderConfig = {
*/
export async function buildOpencodeConfig(
s: CopilotSettings,
- deps: OpencodeModelDeps
+ deps: OpencodeModelDeps,
+ cacheRoot?: string
): Promise> {
const { providerRegistry, backendConfigRegistry } = deps;
@@ -249,13 +302,31 @@ export async function buildOpencodeConfig(
// `restartOnSystemPromptChange`.
const skillManagerReady = SkillManager.hasInstance();
const prompt = buildAgentSystemPrompt();
+
+ // Pre-allow reads of the off-vault shared conversions cache so opencode
+ // doesn't fire an `external_directory` ask on every snapshot the manifest
+ // points at. The glob is matched against the requested absolute path;
+ // `/**` covers the nested `remotes/`, `files/`, and `markers/` subtrees.
+ // Scoped to cacheRoot only — never a broader grant. Injected on BOTH agents
+ // we ever spawn as (`copilot-build` = default, `build` = auto). For the
+ // native `build` agent this only adds the external_directory key — bash/edit
+ // stay at opencode's permissive defaults, so it does not start asking.
+ //
+ // NOTE (version-sensitive, pinned opencode 1.15.13): the `external_directory`
+ // permission key and the `{ "": "allow" }` shape are confirmed against
+ // 1.15.13; re-verify when the pinned opencode version changes.
+ const externalDirectoryPermission = cacheRoot
+ ? { external_directory: { [`${cacheRoot}/**`]: "allow" } }
+ : undefined;
+
config.agent = {
[OPENCODE_BUILTIN_BUILD_AGENT_ID]: {
+ ...(externalDirectoryPermission ? { permission: externalDirectoryPermission } : {}),
prompt,
},
[OPENCODE_COPILOT_BUILD_AGENT_ID]: {
mode: "primary",
- permission: { bash: "ask", edit: "ask" },
+ permission: { bash: "ask", edit: "ask", ...externalDirectoryPermission },
prompt,
},
};
diff --git a/src/agentMode/backends/opencode/descriptor.ts b/src/agentMode/backends/opencode/descriptor.ts
index fb6369f2..0ca9542e 100644
--- a/src/agentMode/backends/opencode/descriptor.ts
+++ b/src/agentMode/backends/opencode/descriptor.ts
@@ -26,6 +26,7 @@ import { OpencodeSettingsPanel } from "./OpencodeSettingsPanel";
import { resolveOpencodeBinary } from "./opencodeBinaryResolver";
import { mapNodeArch, mapNodePlatform } from "./platformResolver";
import { detectBinary } from "@/utils/detectBinary";
+import { cacheRoot } from "@/context/conversionsLocation";
import type { AgentSession } from "@/agentMode/session/AgentSession";
import { simpleBinaryBackendProcess } from "@/agentMode/backends/shared/simpleBinaryBackend";
import type {
@@ -295,7 +296,14 @@ export const OpencodeBackendDescriptor: BackendDescriptor = {
const { providerRegistry, backendConfigRegistry } = args.plugin.modelManagement;
return simpleBinaryBackendProcess(
args,
- new OpencodeBackend({ providerRegistry, backendConfigRegistry })
+ new OpencodeBackend({
+ providerRegistry,
+ backendConfigRegistry,
+ // Activates the opencode external_directory allow rule for the off-vault
+ // shared conversions cache. vaultId/path derivation lives entirely in
+ // conversionsLocation — this backend never duplicates it.
+ getCacheRoot: () => cacheRoot(args.plugin.app),
+ })
);
},
diff --git a/src/agentMode/backends/shared/agentSystemPrompt.test.ts b/src/agentMode/backends/shared/agentSystemPrompt.test.ts
index d5735a9a..122ac60e 100644
--- a/src/agentMode/backends/shared/agentSystemPrompt.test.ts
+++ b/src/agentMode/backends/shared/agentSystemPrompt.test.ts
@@ -146,6 +146,54 @@ describe("buildAgentSystemPrompt", () => {
const prompt = buildAgentSystemPrompt();
expect(prompt).not.toContain(COPILOT_MIYO_SEARCH_STEERING);
});
+
+ describe("project instructions (global parity gate)", () => {
+ it("is byte-identical to the no-arg form when projectInstructions is absent/empty/blank", () => {
+ const baseline = buildAgentSystemPrompt();
+ // The global (no-project) parity guarantee: every "no instructions"
+ // spelling must produce the exact same payload as today's no-arg call.
+ expect(buildAgentSystemPrompt({})).toBe(baseline);
+ expect(buildAgentSystemPrompt({ projectInstructions: undefined })).toBe(baseline);
+ expect(buildAgentSystemPrompt({ projectInstructions: "" })).toBe(baseline);
+ expect(buildAgentSystemPrompt({ projectInstructions: " \n\t " })).toBe(baseline);
+ });
+
+ it("wraps the trimmed project instructions in as the final section", () => {
+ const baseline = buildAgentSystemPrompt();
+ const prompt = buildAgentSystemPrompt({
+ projectInstructions: " Only cite notes tagged #verified. ",
+ });
+ // Existing payload is untouched and stays the prefix; the project body is
+ // appended after a blank-line delimiter, wrapped like the user block.
+ expect(prompt).toBe(
+ `${baseline}\n\n\nOnly cite notes tagged #verified.\n`
+ );
+ });
+
+ it("appends project instructions even when the builtin prompt is disabled", () => {
+ setDisableBuiltinSystemPrompt(true);
+ const baseline = buildAgentSystemPrompt();
+ const prompt = buildAgentSystemPrompt({ projectInstructions: "project body" });
+ expect(prompt).toBe(
+ `${baseline}\n\n\nproject body\n`
+ );
+ expect(prompt.endsWith("")).toBe(true);
+ });
+
+ it("places project instructions after the user custom prompt", () => {
+ updateCachedSystemPrompts([makePrompt("Haiku", "respond in haiku")]);
+ setSelectedPromptTitle("Haiku");
+ const prompt = buildAgentSystemPrompt({ projectInstructions: "PROJECT BODY" });
+ expect(prompt.indexOf("")).toBeLessThan(
+ prompt.indexOf("")
+ );
+ });
+
+ it("never emits a block in the system prompt (it rides the first user prompt now)", () => {
+ const prompt = buildAgentSystemPrompt({ projectInstructions: "PROJECT BODY" });
+ expect(prompt).not.toContain("");
+ });
+ });
});
describe("COPILOT_PROMPT_BASE", () => {
diff --git a/src/agentMode/backends/shared/agentSystemPrompt.ts b/src/agentMode/backends/shared/agentSystemPrompt.ts
index 3c7e9426..2651288b 100644
--- a/src/agentMode/backends/shared/agentSystemPrompt.ts
+++ b/src/agentMode/backends/shared/agentSystemPrompt.ts
@@ -124,8 +124,25 @@ export const COPILOT_PROMPT_BASE = `You are Obsidian Copilot, an AI assistant th
* `getEffectiveUserPrompt`) at call time. Backends call this at their natural
* prompt-injection point — spawn time for opencode/codex, `newSession()` for
* the Claude SDK — so a settings change applies to the next session.
+ *
+ * `opts.projectInstructions` is the owning project's composed instruction
+ * body — the built-in project policy layered ahead of the user's `project.md`
+ * body (an opaque string the caller resolves via
+ * `getComposedProjectInstructions`; this module never touches the `projects/`
+ * layer). When absent or blank the output is byte-identical to the no-project
+ * prompt — the global (no-project) parity guarantee. When present it is
+ * wrapped in `` (mirroring ``)
+ * as the final section, after the user's custom prompt. codex/opencode get the
+ * same composed body for free via native `AGENTS.md` discovery from the
+ * session cwd; this append is the Claude SDK's equivalent, since it has no
+ * cwd-discovery channel.
+ *
+ * Project *file context* (folders/notes/URLs) is NOT part of the system prompt:
+ * it is delivered as a `` block inlined into the session's
+ * first user message (reachable by all three backends), built by the context
+ * materializer's `buildProjectContextBlock`.
*/
-export function buildAgentSystemPrompt(): string {
+export function buildAgentSystemPrompt(opts?: { projectInstructions?: string }): string {
const parts: string[] = [];
// The "Disable builtin system prompt" toggle suppresses only the Copilot
@@ -147,6 +164,15 @@ export function buildAgentSystemPrompt(): string {
if (shouldUseMiyo(getSettings())) {
parts.push(COPILOT_MIYO_SEARCH_STEERING);
}
+ // Extensibility seam — todo/plan steering. Today `AGENT_TODO_PLANNING_STEERING`
+ // is injected ONLY in Project scope (via `composeProjectInstructions`), so this
+ // global prompt stays byte-identical for no-project sessions. To make todo
+ // planning global: push it here AND remove it from `composeProjectInstructions`
+ // — otherwise a Project session receives the section twice (once globally, once
+ // via `` / AGENTS.md). Note the placement decision: pushing
+ // it INSIDE this `!getDisableBuiltinSystemPrompt()` branch ties it to the "Disable
+ // builtin system prompt" toggle (so those users lose Project todo steering too);
+ // push it OUTSIDE the branch if todo planning should survive that toggle.
}
parts.push(buildPillSyntaxDirective());
@@ -156,5 +182,14 @@ export function buildAgentSystemPrompt(): string {
parts.push(`\n${userPrompt}\n`);
}
+ // Optional project-instructions block, last. Absent/blank → nothing pushed,
+ // so the global (no-project) prompt stays byte-identical. Wrapped in
+ // ``, mirroring the `` block
+ // above — the plugin's convention for tagging opaque user content.
+ const projectInstructions = opts?.projectInstructions?.trim();
+ if (projectInstructions) {
+ parts.push(`\n${projectInstructions}\n`);
+ }
+
return parts.join("\n\n");
}
diff --git a/src/agentMode/index.ts b/src/agentMode/index.ts
index e1c072cb..a9818df2 100644
--- a/src/agentMode/index.ts
+++ b/src/agentMode/index.ts
@@ -1,4 +1,4 @@
-import { type App, FileSystemAdapter, Platform } from "obsidian";
+import { type App, Platform } from "obsidian";
import os from "node:os";
import * as path from "node:path";
import type CopilotPlugin from "@/main";
@@ -6,8 +6,7 @@ import { logError } from "@/logger";
import { DEFAULT_SKILLS_FOLDER } from "@/constants";
import { getSettings, subscribeToSettingsChange, type CopilotSettings } from "@/settings/model";
import { subscribeToSystemPromptChange } from "@/system-prompts/state";
-import { copilotAppDataDir } from "@/utils/appPaths";
-import { md5 } from "@/utils/hash";
+import { copilotAppDataDir, getVaultId } from "@/utils/appPaths";
import { buildAgentSystemPrompt } from "./backends/shared/agentSystemPrompt";
import { backendRegistry, listBackendDescriptors } from "./backends/registry";
import type { BackendId } from "./session/types";
@@ -165,9 +164,7 @@ export function createAgentSessionManager(app: App, plugin: CopilotPlugin): Agen
// entries and write conflicts across synced devices (iCloud/Syncthing/
// Obsidian Sync). Scoped by a vault id (hash of the vault path) so vaults
// don't share one file. Agent Mode is desktop-only, so Node fs is fine.
- const vaultAdapter = app.vault.adapter;
- const vaultBasePath = vaultAdapter instanceof FileSystemAdapter ? vaultAdapter.getBasePath() : "";
- const vaultId = vaultBasePath ? md5(vaultBasePath).slice(0, 8) : "default";
+ const vaultId = getVaultId(app);
const indexPath = path.join(
copilotAppDataDir(os.homedir()),
"vaults",
diff --git a/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts b/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts
index 17c2e13a..68f1955e 100644
--- a/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts
+++ b/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts
@@ -608,6 +608,178 @@ function errorResultMessage(errors: string[]): SDKMessage {
};
}
+describe("ClaudeSdkBackendProcess project profile provider", () => {
+ beforeEach(() => {
+ queryMock.mockReset();
+ createSdkMcpServerMock.mockClear();
+ });
+
+ // Echoes the resolved project instructions so a test can read back exactly
+ // what the SDK passed to `getSystemPromptAppend` per session. Mirrors the
+ // descriptor's real `(opts) => buildAgentSystemPrompt(opts)`.
+ const echoAppend = (opts?: { projectInstructions?: string }): string => {
+ const parts = ["BASE"];
+ if (opts?.projectInstructions) parts.push(opts.projectInstructions);
+ return parts.join("\n\n");
+ };
+
+ function makeProc(getSystemPromptAppend: (opts?: { projectInstructions?: string }) => string) {
+ return new ClaudeSdkBackendProcess({
+ pathToClaudeCodeExecutable: "/usr/local/bin/claude",
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ app: { vault: {} } as any,
+ clientVersion: "1.2.3",
+ descriptor: fakeDescriptor(),
+ getSystemPromptAppend,
+ });
+ }
+
+ function appendOf(callIndex: number): unknown {
+ const opts = (getPromptQueryCalls()[callIndex][0] as { options: Record })
+ .options;
+ return (opts.systemPrompt as { append?: unknown } | undefined)?.append;
+ }
+
+ it("global parity: an unset provider resolves the byte-identical no-project append", async () => {
+ queryMock.mockImplementation(() => makeQuery([resultMessage()]));
+ const proc = makeProc(echoAppend);
+
+ // No provider wired, no projectId → same as today's global path.
+ const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] });
+ proc.registerSessionHandler(sessionId, () => {});
+ await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] });
+
+ expect(appendOf(0)).toBe("BASE");
+ });
+
+ it("global parity: GLOBAL_SCOPE / unknown project (provider returns undefined) yields the global append", async () => {
+ queryMock.mockImplementation(() => makeQuery([resultMessage()]));
+ const proc = makeProc(echoAppend);
+ proc.setProjectProfileProvider(() => undefined);
+
+ const { sessionId } = await proc.newSession({
+ cwd: "/vault",
+ mcpServers: [],
+ projectId: "__global__",
+ });
+ proc.registerSessionHandler(sessionId, () => {});
+ await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] });
+
+ expect(appendOf(0)).toBe("BASE");
+ });
+
+ it("appends the owning project's instructions when the provider resolves a profile", async () => {
+ queryMock.mockImplementation(() => makeQuery([resultMessage()]));
+ const proc = makeProc(echoAppend);
+ proc.setProjectProfileProvider((id) =>
+ id === "proj-1" ? { id, systemPrompt: "Cite only #verified notes." } : undefined
+ );
+
+ const { sessionId } = await proc.newSession({
+ cwd: "/vault",
+ mcpServers: [],
+ projectId: "proj-1",
+ });
+ proc.registerSessionHandler(sessionId, () => {});
+ await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] });
+
+ expect(appendOf(0)).toBe("BASE\n\nCite only #verified notes.");
+ });
+
+ it("resolves each session's own project on one process (per-session, not process-global)", async () => {
+ queryMock.mockImplementation(() => makeQuery([resultMessage()]));
+ const proc = makeProc(echoAppend);
+ proc.setProjectProfileProvider((id) => {
+ if (id === "proj-A") return { id, systemPrompt: "A rules" };
+ if (id === "proj-B") return { id, systemPrompt: "B rules" };
+ return undefined;
+ });
+
+ const a = await proc.newSession({ cwd: "/vault", mcpServers: [], projectId: "proj-A" });
+ const b = await proc.newSession({ cwd: "/vault", mcpServers: [], projectId: "proj-B" });
+ proc.registerSessionHandler(a.sessionId, () => {});
+ proc.registerSessionHandler(b.sessionId, () => {});
+
+ await proc.prompt({ sessionId: a.sessionId, prompt: [{ type: "text", text: "hi" }] });
+ await proc.prompt({ sessionId: b.sessionId, prompt: [{ type: "text", text: "hi" }] });
+
+ // The two prompt calls carry each session's own project instructions.
+ expect(appendOf(0)).toBe("BASE\n\nA rules");
+ expect(appendOf(1)).toBe("BASE\n\nB rules");
+ });
+
+ it("captures project instructions at newSession, ignoring later provider swaps mid-session", async () => {
+ queryMock.mockImplementation(() => makeQuery([resultMessage()]));
+ const proc = makeProc(echoAppend);
+ proc.setProjectProfileProvider((id) =>
+ id === "proj-1" ? { id, systemPrompt: "ORIGINAL" } : undefined
+ );
+
+ const { sessionId } = await proc.newSession({
+ cwd: "/vault",
+ mcpServers: [],
+ projectId: "proj-1",
+ });
+ proc.registerSessionHandler(sessionId, () => {});
+ // Swap the provider after the session exists; the captured append must win.
+ proc.setProjectProfileProvider((id) =>
+ id === "proj-1" ? { id, systemPrompt: "CHANGED" } : undefined
+ );
+ await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] });
+
+ expect(appendOf(0)).toBe("BASE\n\nORIGINAL");
+ });
+});
+
+describe("ClaudeSdkBackendProcess additional directories", () => {
+ beforeEach(() => {
+ queryMock.mockReset();
+ createSdkMcpServerMock.mockClear();
+ });
+
+ function makeProc() {
+ return new ClaudeSdkBackendProcess({
+ pathToClaudeCodeExecutable: "/usr/local/bin/claude",
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ app: { vault: {} } as any,
+ clientVersion: "1.2.3",
+ descriptor: fakeDescriptor(),
+ });
+ }
+
+ it("reports support for additionalDirectories (stable SDK option)", () => {
+ expect(makeProc().supportsAdditionalDirectories()).toBe(true);
+ });
+
+ it("forwards captured additionalDirectories into options on every turn", async () => {
+ queryMock.mockImplementation(() => makeQuery([resultMessage()]));
+ const proc = makeProc();
+
+ const { sessionId } = await proc.newSession({
+ cwd: "/vault",
+ mcpServers: [],
+ additionalDirectories: ["/abs/context-a", "/abs/context-b"],
+ });
+ proc.registerSessionHandler(sessionId, () => {});
+ await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] });
+
+ const call = getPromptQueryCalls()[0][0] as { options: { additionalDirectories?: string[] } };
+ expect(call.options.additionalDirectories).toEqual(["/abs/context-a", "/abs/context-b"]);
+ });
+
+ it("omits additionalDirectories from options when none were captured", async () => {
+ queryMock.mockImplementation(() => makeQuery([resultMessage()]));
+ const proc = makeProc();
+
+ const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] });
+ proc.registerSessionHandler(sessionId, () => {});
+ await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] });
+
+ const call = getPromptQueryCalls()[0][0] as { options: { additionalDirectories?: string[] } };
+ expect(call.options.additionalDirectories).toBeUndefined();
+ });
+});
+
describe("ClaudeSdkBackendProcess.prompt auth gate", () => {
beforeEach(() => {
queryMock.mockReset();
diff --git a/src/agentMode/sdk/ClaudeSdkBackendProcess.ts b/src/agentMode/sdk/ClaudeSdkBackendProcess.ts
index ea2d00fc..f085ce8f 100644
--- a/src/agentMode/sdk/ClaudeSdkBackendProcess.ts
+++ b/src/agentMode/sdk/ClaudeSdkBackendProcess.ts
@@ -46,6 +46,7 @@ import type {
OpenSessionOutput,
PermissionDecision,
PermissionPrompt,
+ ProjectProfile,
PromptInput,
PromptOutput,
ResumeSessionInput,
@@ -55,7 +56,9 @@ import type {
SessionUpdateHandler,
StopReason,
} from "@/agentMode/session/types";
+import type { ProjectScopeId } from "@/agentMode/session/scope";
import { AuthRequiredError, MethodUnsupportedError } from "@/agentMode/session/errors";
+import { createClaudeTaskPlanState, type ClaudeTaskPlanState } from "./claudeTodoPlan";
import { createTranslatorState, mapStopReason, translateSdkMessage } from "./sdkMessageTranslator";
import { PermissionBridge, type AskUserQuestionPrompter } from "./permissionBridge";
import {
@@ -75,6 +78,14 @@ import { guardSdkStreamStall } from "./sdkStreamStallGuard";
interface SessionState {
cwd: string | null;
+ /**
+ * Scope this session belongs to, captured from the Open/Resume input so the
+ * SDK can resolve the owning project's instructions. One claude process can
+ * host many sessions across different projects; the projectId lives per
+ * session (not process-global) so each resolves its own instructions.
+ * `undefined` / `GLOBAL_SCOPE` means the implicit global workspace.
+ */
+ projectId?: ProjectScopeId;
/**
* Drives whether the next `query()` passes `resume: ` (continue
* the persisted conversation) or `sessionId: ` (mint a new SDK-side
@@ -91,6 +102,18 @@ interface SessionState {
*/
effort?: EffortLevel;
mcpServers: Record;
+ /**
+ * Absolute extra workspace roots captured from the Open/Resume input,
+ * forwarded into `options.additionalDirectories` on every `query()` for this
+ * session. The SDK option is stable (`sdk.d.ts`), so unlike the ACP backends
+ * this needs no capability gate. Absent / empty means cwd is the only root.
+ */
+ additionalDirectories?: string[];
+ /**
+ * Session-lived todo/Task accumulator shared across this session's queries
+ * (translator state is per-query; Task ids must survive turns).
+ */
+ claudeTaskPlan: ClaudeTaskPlanState;
active?: Query;
/**
* Snapshot of the composed Copilot system prompt (base framing + pill-syntax
@@ -131,8 +154,14 @@ export interface ClaudeSdkBackendProcessOptions {
* + user custom prompt). Read once per `newSession()` so a settings change
* applies to the next session rather than mid-turn. Empty string / undefined
* disables the append.
+ *
+ * `projectInstructions` is the owning project's resolved instruction body
+ * (from {@link setProjectProfileProvider}); the descriptor composes it into
+ * the append. An omitted object, or one whose `projectInstructions` is
+ * `undefined` (no project / GLOBAL_SCOPE / unset provider), yields the
+ * byte-identical global prompt.
*/
- getSystemPromptAppend?: () => string | undefined;
+ getSystemPromptAppend?: (opts?: { projectInstructions?: string }) => string | undefined;
/**
* User-defined env vars merged onto `process.env` for the spawned `claude`
* CLI. Read per `prompt()` so settings edits apply on the next turn.
@@ -176,6 +205,15 @@ export class ClaudeSdkBackendProcess implements BackendProcess {
null;
private askUserQuestionPrompter: AskUserQuestionPrompter | null = null;
private isReadOnlySession: ((sessionId: SessionId) => boolean) | null = null;
+ /**
+ * Resolves a session's owning-project instructions by scope id. Injected by
+ * the manager via {@link setProjectProfileProvider} (mirrors the prompter
+ * setters). Null until wired, and returns `undefined` for `GLOBAL_SCOPE` /
+ * unknown projects — both paths fall back to the global prompt.
+ */
+ private projectProfileProvider:
+ | ((projectId: ProjectScopeId) => ProjectProfile | undefined)
+ | null = null;
private exitListeners = new Set<() => void>();
private shuttingDown = false;
private readonly bridge: PermissionBridge;
@@ -225,6 +263,24 @@ export class ClaudeSdkBackendProcess implements BackendProcess {
this.askUserQuestionPrompter = fn;
}
+ setProjectProfileProvider(fn: (projectId: ProjectScopeId) => ProjectProfile | undefined): void {
+ this.projectProfileProvider = fn;
+ }
+
+ /**
+ * Compose this session's system-prompt append, resolving the owning
+ * project's instructions (if any). A defined non-global `projectId` consults
+ * the injected provider; `undefined` projectId, an unset provider, or a
+ * provider that returns `undefined` (GLOBAL_SCOPE / unknown project) all
+ * yield no project instructions → the append is byte-identical to the global
+ * (no-project) prompt. Captured at `newSession`/`resumeSession` time so a
+ * settings change applies to the next session, not mid-conversation.
+ */
+ private resolveSystemPromptAppend(projectId: ProjectScopeId | undefined): string {
+ const profile = projectId !== undefined ? this.projectProfileProvider?.(projectId) : undefined;
+ return this.opts.getSystemPromptAppend?.({ projectInstructions: profile?.systemPrompt }) ?? "";
+ }
+
registerSessionHandler(sessionId: SessionId, handler: SessionUpdateHandler): () => void {
this.sessionHandlers.set(sessionId, handler);
const buffered = this.pendingUpdates.get(sessionId);
@@ -246,7 +302,11 @@ export class ClaudeSdkBackendProcess implements BackendProcess {
}
async newSession(params: OpenSessionInput): Promise {
- logSdkOutbound("newSession", { cwd: params.cwd, mcpServers: params.mcpServers });
+ logSdkOutbound("newSession", {
+ cwd: params.cwd,
+ mcpServers: params.mcpServers,
+ projectId: params.projectId ?? null,
+ });
const sessionId = uuidv4();
const cwd = params.cwd ?? null;
const mcp: Record = {};
@@ -263,10 +323,13 @@ export class ClaudeSdkBackendProcess implements BackendProcess {
this.sessions.set(sessionId, {
cwd,
+ projectId: params.projectId,
firstPromptStarted: false,
mcpServers: mcp,
model: seedModelId,
- systemPromptAppend: this.opts.getSystemPromptAppend?.() ?? "",
+ additionalDirectories: params.additionalDirectories,
+ systemPromptAppend: this.resolveSystemPromptAppend(params.projectId),
+ claudeTaskPlan: createClaudeTaskPlanState(),
});
const state = this.computeState(sessionId);
@@ -337,6 +400,12 @@ export class ClaudeSdkBackendProcess implements BackendProcess {
if (session.model) options.model = session.model;
if (session.permissionMode) options.permissionMode = session.permissionMode;
if (session.effort) options.effort = session.effort;
+ // Widen the agent's searchable roots beyond cwd. The SDK option is stable,
+ // so this is forwarded unconditionally (no capability gate) whenever the
+ // session captured extra roots at open/resume.
+ if (session.additionalDirectories?.length) {
+ options.additionalDirectories = session.additionalDirectories;
+ }
// Keep the toggle authoritative. Leaving `thinking` unset when off lets the
// model's default take over — Sonnet 4.6 / Opus 4.6 default to adaptive
// "summarized", so reasoning keeps streaming — so disable it explicitly.
@@ -390,7 +459,7 @@ export class ClaudeSdkBackendProcess implements BackendProcess {
onStall: (idleMs) => logSdkError("←", "stream:stalled", { idleMs }, params.sessionId),
});
- const translatorState = createTranslatorState();
+ const translatorState = createTranslatorState(session.claudeTaskPlan);
let stopReason: StopReason = "end_turn";
let resultErrorMessage: string | null = null;
try {
@@ -635,7 +704,7 @@ export class ClaudeSdkBackendProcess implements BackendProcess {
async resumeSession(params: ResumeSessionInput): Promise {
logSdkOutbound(
"resumeSession",
- { cwd: params.cwd, mcpServers: params.mcpServers },
+ { cwd: params.cwd, mcpServers: params.mcpServers, projectId: params.projectId ?? null },
params.sessionId
);
const cwd = params.cwd ?? null;
@@ -650,10 +719,13 @@ export class ClaudeSdkBackendProcess implements BackendProcess {
this.sessions.set(params.sessionId, {
cwd,
+ projectId: params.projectId,
firstPromptStarted: true,
mcpServers: mcp,
model: seedModelId,
- systemPromptAppend: this.opts.getSystemPromptAppend?.() ?? "",
+ additionalDirectories: params.additionalDirectories,
+ systemPromptAppend: this.resolveSystemPromptAppend(params.projectId),
+ claudeTaskPlan: createClaudeTaskPlanState(),
});
const state = this.computeState(params.sessionId);
@@ -676,6 +748,15 @@ export class ClaudeSdkBackendProcess implements BackendProcess {
return true;
}
+ /**
+ * The SDK's `options.additionalDirectories` is a stable option (`sdk.d.ts`),
+ * so the Claude backend always honors widened roots — no capability probe is
+ * needed (unlike the ACP backends, which gate on an experimental wire field).
+ */
+ supportsAdditionalDirectories(): boolean {
+ return true;
+ }
+
async shutdown(): Promise {
this.shuttingDown = true;
for (const session of this.sessions.values()) {
diff --git a/src/agentMode/sdk/claudeTodoPlan.test.ts b/src/agentMode/sdk/claudeTodoPlan.test.ts
new file mode 100644
index 00000000..e88d551c
--- /dev/null
+++ b/src/agentMode/sdk/claudeTodoPlan.test.ts
@@ -0,0 +1,284 @@
+import {
+ createClaudeTaskPlanState,
+ planUpdateFromClaudeToolResult,
+ planUpdateFromClaudeToolUse,
+} from "./claudeTodoPlan";
+
+function entriesOf(update: ReturnType) {
+ expect(update).not.toBeNull();
+ if (update?.sessionUpdate !== "plan") throw new Error("expected a plan update");
+ return update.entries;
+}
+
+describe("claudeTodoPlan — TodoWrite whole-list shape", () => {
+ it("converts input.todos into plan entries with stable content text", () => {
+ const state = createClaudeTaskPlanState();
+ const update = planUpdateFromClaudeToolUse(state, "tu1", "TodoWrite", {
+ todos: [
+ { content: "Brainstorm imagery", status: "in_progress", activeForm: "Brainstorming" },
+ { content: "Draft haiku", status: "pending", activeForm: "Drafting" },
+ ],
+ });
+ expect(entriesOf(update)).toEqual([
+ { content: "Brainstorm imagery", status: "in_progress", priority: "medium" },
+ { content: "Draft haiku", status: "pending", priority: "medium" },
+ ]);
+ });
+
+ it("filters malformed items and returns null when nothing valid remains", () => {
+ const state = createClaudeTaskPlanState();
+ expect(
+ planUpdateFromClaudeToolUse(state, "tu1", "TodoWrite", {
+ todos: [{ content: "", status: "pending" }, { content: "x", status: "bogus" }, "junk"],
+ })
+ ).toBeNull();
+ expect(planUpdateFromClaudeToolUse(state, "tu1", "TodoWrite", { todos: "nope" })).toBeNull();
+ });
+
+ it("suppresses re-emission of an identical list (streaming injection points)", () => {
+ const state = createClaudeTaskPlanState();
+ const input = { todos: [{ content: "a", status: "pending" }] };
+ expect(planUpdateFromClaudeToolUse(state, "tu1", "TodoWrite", input)).not.toBeNull();
+ expect(planUpdateFromClaudeToolUse(state, "tu1", "TodoWrite", input)).toBeNull();
+ });
+
+ it("clears the snapshot on a genuine empty list but not on all-malformed input", () => {
+ const state = createClaudeTaskPlanState();
+ planUpdateFromClaudeToolUse(state, "tu1", "TodoWrite", {
+ todos: [{ content: "a", status: "pending" }],
+ });
+ // A non-empty array that filters to nothing is garbage — keep the good list.
+ expect(
+ planUpdateFromClaudeToolUse(state, "tu2", "TodoWrite", {
+ todos: [{ content: "", status: "pending" }],
+ })
+ ).toBeNull();
+ // A real `todos: []` is a clear — emit empty entries to reset downstream.
+ expect(planUpdateFromClaudeToolUse(state, "tu3", "TodoWrite", { todos: [] })).toEqual({
+ sessionUpdate: "plan",
+ entries: [],
+ });
+ });
+});
+
+describe("claudeTodoPlan — Task tools split shape", () => {
+ it("accumulates TaskCreate → tool_result id binding → TaskUpdate status changes", () => {
+ const state = createClaudeTaskPlanState();
+ // Creates carry no id yet — nothing to show until the result binds one.
+ expect(
+ planUpdateFromClaudeToolUse(state, "tuA", "TaskCreate", {
+ subject: "Brainstorm imagery",
+ activeForm: "Brainstorming",
+ })
+ ).toBeNull();
+ const afterBind = planUpdateFromClaudeToolResult(state, "tuA", {
+ task: { id: "1", subject: "Brainstorm imagery" },
+ });
+ expect(entriesOf(afterBind)).toEqual([
+ { content: "Brainstorm imagery", status: "pending", priority: "medium" },
+ ]);
+
+ expect(
+ planUpdateFromClaudeToolUse(state, "tuB", "TaskCreate", { subject: "Draft" })
+ ).toBeNull();
+ planUpdateFromClaudeToolResult(state, "tuB", { task: { id: "2", subject: "Draft" } });
+
+ const afterUpdate = planUpdateFromClaudeToolUse(state, "tuC", "TaskUpdate", {
+ taskId: "1",
+ status: "in_progress",
+ });
+ expect(entriesOf(afterUpdate)).toEqual([
+ { content: "Brainstorm imagery", status: "in_progress", priority: "medium" },
+ { content: "Draft", status: "pending", priority: "medium" },
+ ]);
+ });
+
+ it("binds ids from every observed tool_result shape (incl. the real `Task #N` string)", () => {
+ for (const content of [
+ // The shape real claude CLIs (>= 2.1.142) actually emit.
+ "Task #1 created successfully: keep",
+ [{ type: "text", text: "Task #1 created successfully: keep" }],
+ // Object / JSON shapes kept for forward-compat with other CLI versions.
+ { task: { id: "k", subject: "keep" } },
+ JSON.stringify({ task: { id: "k", subject: "keep" } }),
+ [{ type: "text", text: JSON.stringify({ task: { id: "k", subject: "keep" } }) }],
+ ]) {
+ const state = createClaudeTaskPlanState();
+ planUpdateFromClaudeToolUse(state, "tu", "TaskCreate", { subject: "keep" });
+ const update = planUpdateFromClaudeToolResult(state, "tu", content);
+ expect(entriesOf(update)).toEqual([
+ { content: "keep", status: "pending", priority: "medium" },
+ ]);
+ }
+ });
+
+ it("binds the `#N` ordinal from the real string result and resolves TaskUpdate by it", () => {
+ const state = createClaudeTaskPlanState();
+ planUpdateFromClaudeToolUse(state, "tu1", "TaskCreate", { subject: "Choose theme" });
+ // Real CLI result is a human string; the id is the `#N` ordinal, and the
+ // echoed subject is ignored in favor of the authoritative pending subject.
+ const bound = planUpdateFromClaudeToolResult(
+ state,
+ "tu1",
+ "Task #1 created successfully: Choose theme (echoed copy)"
+ );
+ expect(entriesOf(bound)).toEqual([
+ { content: "Choose theme", status: "pending", priority: "medium" },
+ ]);
+ // TaskUpdate references that same ordinal `"1"` — the link that was broken
+ // when the string result couldn't be parsed into an id.
+ const updated = planUpdateFromClaudeToolUse(state, "tu2", "TaskUpdate", {
+ taskId: "1",
+ status: "completed",
+ });
+ expect(entriesOf(updated)).toEqual([
+ { content: "Choose theme", status: "completed", priority: "medium" },
+ ]);
+ });
+
+ it("TaskUpdate deleted removes the entry; unknown ids and unparseable results are ignored", () => {
+ const state = createClaudeTaskPlanState();
+ planUpdateFromClaudeToolUse(state, "a", "TaskCreate", { subject: "keep" });
+ planUpdateFromClaudeToolResult(state, "a", { task: { id: "k" } });
+ planUpdateFromClaudeToolUse(state, "b", "TaskCreate", { subject: "drop" });
+ planUpdateFromClaudeToolResult(state, "b", { task: { id: "d" } });
+
+ // Unknown id / unmatched result: no emission, no state change.
+ expect(
+ planUpdateFromClaudeToolUse(state, "x", "TaskUpdate", {
+ taskId: "ghost",
+ status: "completed",
+ })
+ ).toBeNull();
+ expect(
+ planUpdateFromClaudeToolResult(state, "never-created", { task: { id: "z" } })
+ ).toBeNull();
+
+ const afterDelete = planUpdateFromClaudeToolUse(state, "y", "TaskUpdate", {
+ taskId: "d",
+ status: "deleted",
+ });
+ expect(entriesOf(afterDelete)).toEqual([
+ { content: "keep", status: "pending", priority: "medium" },
+ ]);
+
+ // Deleting the last entry emits an EMPTY plan so downstream clears.
+ const afterLast = planUpdateFromClaudeToolUse(state, "z", "TaskUpdate", {
+ taskId: "k",
+ status: "deleted",
+ });
+ expect(entriesOf(afterLast)).toEqual([]);
+ });
+
+ it("consumes the pending TaskCreate even when its result is null/unparseable (no leak, no late bind)", () => {
+ const state = createClaudeTaskPlanState();
+ planUpdateFromClaudeToolUse(state, "tu", "TaskCreate", { subject: "ghost" });
+
+ // is_error result is passed as null content by the translator → the pending
+ // entry is consumed (spent) and nothing is emitted.
+ expect(planUpdateFromClaudeToolResult(state, "tu", null)).toBeNull();
+
+ // A second (stray re-delivered) result for the SAME id must NOT resurrect
+ // the entry — the pending was already consumed, so even a well-formed
+ // payload now no-ops instead of binding a phantom task.
+ expect(
+ planUpdateFromClaudeToolResult(state, "tu", { task: { id: "1", subject: "ghost" } })
+ ).toBeNull();
+ });
+
+ it("drops a fully-completed group when the next topic's first TaskCreate binds", () => {
+ // Official Todo lifecycle step 4: "Removed when all tasks in a group are
+ // completed." A new session topic (e.g. HK guide → Japan guide) must not
+ // stack its tasks onto the previous, already-finished group.
+ const state = createClaudeTaskPlanState();
+ for (const [tu, id, subject] of [
+ ["hk1", "1", "Plan HK transit"],
+ ["hk2", "2", "Plan HK food"],
+ ] as const) {
+ planUpdateFromClaudeToolUse(state, tu, "TaskCreate", { subject });
+ planUpdateFromClaudeToolResult(state, tu, `Task #${id} created successfully: ${subject}`);
+ planUpdateFromClaudeToolUse(state, `${tu}-done`, "TaskUpdate", {
+ taskId: id,
+ status: "completed",
+ });
+ }
+
+ // The new topic's FIRST create lands while every prior task is completed —
+ // the old group is dropped, leaving only the fresh task.
+ planUpdateFromClaudeToolUse(state, "jp1", "TaskCreate", { subject: "Plan Japan transit" });
+ const firstJapan = planUpdateFromClaudeToolResult(
+ state,
+ "jp1",
+ "Task #7 created successfully: Plan Japan transit"
+ );
+ expect(entriesOf(firstJapan)).toEqual([
+ { content: "Plan Japan transit", status: "pending", priority: "medium" },
+ ]);
+
+ // The SECOND create of the same batch sees a pending task → it appends,
+ // it does NOT wipe the just-started group.
+ planUpdateFromClaudeToolUse(state, "jp2", "TaskCreate", { subject: "Plan Japan food" });
+ const secondJapan = planUpdateFromClaudeToolResult(
+ state,
+ "jp2",
+ "Task #8 created successfully: Plan Japan food"
+ );
+ expect(entriesOf(secondJapan)).toEqual([
+ { content: "Plan Japan transit", status: "pending", priority: "medium" },
+ { content: "Plan Japan food", status: "pending", priority: "medium" },
+ ]);
+ });
+
+ it("keeps a still-active group intact when a mid-plan TaskCreate binds", () => {
+ // The clear only fires when EVERY task is completed. While a plan is still
+ // in flight (some done, some open) a freshly-added step must APPEND, not
+ // wipe — completed steps stay visible as progress within the live plan.
+ // (Real claude burst-creates a whole plan up front, so binds always land
+ // while tasks are pending; completion only happens afterwards. This guards
+ // that a genuine mid-plan addition is never mistaken for a new group.)
+ const state = createClaudeTaskPlanState();
+ for (const [tu, id, subject] of [
+ ["c1", "1", "Outline"],
+ ["c2", "2", "Draft"],
+ ] as const) {
+ planUpdateFromClaudeToolUse(state, tu, "TaskCreate", { subject });
+ planUpdateFromClaudeToolResult(state, tu, `Task #${id} created successfully: ${subject}`);
+ }
+ planUpdateFromClaudeToolUse(state, "c1-done", "TaskUpdate", {
+ taskId: "1",
+ status: "completed",
+ });
+ planUpdateFromClaudeToolUse(state, "c2-go", "TaskUpdate", {
+ taskId: "2",
+ status: "in_progress",
+ });
+
+ // #2 is still in_progress → the group is NOT fully completed → append.
+ planUpdateFromClaudeToolUse(state, "c3", "TaskCreate", { subject: "Polish" });
+ const update = planUpdateFromClaudeToolResult(
+ state,
+ "c3",
+ "Task #3 created successfully: Polish"
+ );
+ expect(entriesOf(update)).toEqual([
+ { content: "Outline", status: "completed", priority: "medium" },
+ { content: "Draft", status: "in_progress", priority: "medium" },
+ { content: "Polish", status: "pending", priority: "medium" },
+ ]);
+ });
+
+ it("survives across translator generations — the state is session-lived", () => {
+ // Simulates turn 1 creating the task and turn 2 (fresh translator, same
+ // shared state) updating it by id.
+ const state = createClaudeTaskPlanState();
+ planUpdateFromClaudeToolUse(state, "t1", "TaskCreate", { subject: "step" });
+ planUpdateFromClaudeToolResult(state, "t1", { task: { id: "42" } });
+ const turn2 = planUpdateFromClaudeToolUse(state, "t2", "TaskUpdate", {
+ taskId: "42",
+ status: "completed",
+ });
+ expect(entriesOf(turn2)).toEqual([
+ { content: "step", status: "completed", priority: "medium" },
+ ]);
+ });
+});
diff --git a/src/agentMode/sdk/claudeTodoPlan.ts b/src/agentMode/sdk/claudeTodoPlan.ts
new file mode 100644
index 00000000..ef609ac4
--- /dev/null
+++ b/src/agentMode/sdk/claudeTodoPlan.ts
@@ -0,0 +1,238 @@
+/**
+ * Normalizes Claude's execution todo list into the session-domain `plan`
+ * update — the same shape the ACP backends deliver — so the trail's PlanPill
+ * and the project-info Progress section stay backend- and version-agnostic.
+ *
+ * The Claude runtime ships TWO wire shapes, decided by the user's installed
+ * `claude` CLI (not by our pinned npm SDK):
+ * - `TodoWrite` (CLI < 2.1.142, or `CLAUDE_CODE_ENABLE_TASKS=0`): one call
+ * carrying the WHOLE list in `input.todos`.
+ * - Task tools (CLI >= 2.1.142, the default): `TaskCreate` per item, the
+ * task id arriving in the matching `tool_result`. The id is the `#N`
+ * ordinal: real CLIs return the human-readable string
+ * `"Task #N created successfully: "` (a `{task:{id,subject}}`
+ * object/JSON is also accepted for forward-compat). Then
+ * `TaskUpdate {taskId, status}` references that same `N`.
+ * Both converge here; display text is the stable title field (`content` /
+ * `subject`), deliberately NOT `activeForm` (per-status text would make the
+ * same entry's label jump between states and differ across shapes).
+ *
+ * State is held PER CLAUDE SESSION (not per query): Task ids created in turn
+ * 1 must still resolve when turn 2's `TaskUpdate` references them, and the
+ * translator's own state is rebuilt for every `query()` call.
+ */
+import type { AgentPlanEntry, SessionUpdate } from "@/agentMode/session/types";
+
+const TODO_STATUSES = ["pending", "in_progress", "completed"] as const;
+
+type TodoStatus = AgentPlanEntry["status"];
+
+function asTodoStatus(value: unknown): TodoStatus | null {
+ return TODO_STATUSES.includes(value as TodoStatus) ? (value as TodoStatus) : null;
+}
+
+export interface ClaudeTaskPlanState {
+ /** TaskCreate inputs waiting for their tool_result to deliver the task id. */
+ pendingCreatesByToolUseId: Map;
+ tasksById: Map;
+ nextOrder: number;
+ /**
+ * Signature of the last emitted entries — suppresses re-emits when the
+ * multiple translator injection points (stream delta, block stop, assistant
+ * fallback) observe the same final input.
+ */
+ lastSignature: string | null;
+}
+
+export function createClaudeTaskPlanState(): ClaudeTaskPlanState {
+ return {
+ pendingCreatesByToolUseId: new Map(),
+ tasksById: new Map(),
+ nextOrder: 0,
+ lastSignature: null,
+ };
+}
+
+/**
+ * Feed one native, top-level tool_use (TodoWrite / TaskCreate / TaskUpdate).
+ * Callers are responsible for the native + top-level filter (no `mcpServer`,
+ * `parent_tool_use_id == null`) — subagent todos must not pollute the
+ * session-level list. Returns a `plan` update when the canonical list
+ * changed, else null.
+ */
+export function planUpdateFromClaudeToolUse(
+ state: ClaudeTaskPlanState,
+ toolUseId: string,
+ toolName: string,
+ rawInput: unknown
+): SessionUpdate | null {
+ const input = isRecord(rawInput) ? rawInput : null;
+ switch (toolName) {
+ case "TodoWrite": {
+ const todos = input?.todos;
+ if (!Array.isArray(todos)) return null;
+ const entries: AgentPlanEntry[] = [];
+ for (const todo of todos) {
+ if (!isRecord(todo)) continue;
+ const status = asTodoStatus(todo.status);
+ if (typeof todo.content !== "string" || todo.content.length === 0 || !status) continue;
+ entries.push({ content: todo.content, status, priority: "medium" });
+ }
+ // A genuinely empty list (`todos: []`) is a clear — emit it so the
+ // snapshot resets, matching the Task-tools path (deleting the last task
+ // emits an empty plan). But a NON-empty array that filtered down to
+ // nothing is malformed input, not a clear: don't wipe a good list on garbage.
+ if (entries.length === 0 && todos.length > 0) return null;
+ return emitIfChanged(state, entries);
+ }
+ case "TaskCreate": {
+ const subject = typeof input?.subject === "string" ? input.subject.trim() : "";
+ if (!subject) return null;
+ // No emission yet — the entry joins the list once the tool_result
+ // delivers its id (which also implicitly scopes results to top-level
+ // creates: subagent results never match this map).
+ state.pendingCreatesByToolUseId.set(toolUseId, { subject });
+ return null;
+ }
+ case "TaskUpdate": {
+ const taskId = typeof input?.taskId === "string" ? input.taskId : "";
+ if (!taskId) return null;
+ if (input?.status === "deleted") {
+ if (!state.tasksById.delete(taskId)) return null;
+ return emitIfChanged(state, snapshotEntries(state));
+ }
+ const status = asTodoStatus(input?.status);
+ const task = state.tasksById.get(taskId);
+ // Unknown id (e.g. created by a subagent, or a pre-resume task) is
+ // ignored rather than fabricated — we'd have no subject to show.
+ if (!task || !status || task.status === status) return null;
+ task.status = status;
+ return emitIfChanged(state, snapshotEntries(state));
+ }
+ default:
+ return null;
+ }
+}
+
+/**
+ * Feed one tool_result. Only results matching a pending TaskCreate bind an
+ * id; everything else is ignored. The result content arrives in several
+ * observed shapes: the plain string `"Task #N created successfully: ..."`
+ * (real CLI), a `{task:{id,subject}}` object, a JSON string of it, or a
+ * `[{type:"text", text:"..."}]` block list wrapping either form.
+ */
+export function planUpdateFromClaudeToolResult(
+ state: ClaudeTaskPlanState,
+ toolUseId: string,
+ content: unknown
+): SessionUpdate | null {
+ const pending = state.pendingCreatesByToolUseId.get(toolUseId);
+ if (!pending) return null;
+ // Consume the pending entry up front: a tool_use id's result arrives exactly
+ // once, so whether we can parse it or not, the pending record is spent —
+ // dropping it here stops failed/unparseable results from accumulating over a
+ // long session. Callers pass `null` content for an is_error result, which
+ // lands here as "consume but emit nothing".
+ state.pendingCreatesByToolUseId.delete(toolUseId);
+ const result = readTaskCreateResult(content);
+ if (!result) return null;
+ // Official Todo lifecycle step 4 ("Removed when all tasks in a group are
+ // completed"): a finished group must not linger once the next one starts.
+ // When this newly-bound create lands while every existing task is already
+ // completed, it is the first task of a NEW group (e.g. a second topic in the
+ // same claude session) — drop the old group so they don't stack. The check
+ // self-batches: once this pending task is in the map the group is no longer
+ // all-completed, so the rest of the batch appends instead of wiping.
+ if (isGroupFullyCompleted(state)) {
+ state.tasksById.clear();
+ state.nextOrder = 0;
+ }
+ state.tasksById.set(result.id, {
+ subject: result.subject ?? pending.subject,
+ status: "pending",
+ order: state.nextOrder++,
+ });
+ return emitIfChanged(state, snapshotEntries(state));
+}
+
+/** True only for a non-empty list whose every task has reached `completed`. */
+function isGroupFullyCompleted(state: ClaudeTaskPlanState): boolean {
+ if (state.tasksById.size === 0) return false;
+ for (const task of state.tasksById.values()) {
+ if (task.status !== "completed") return false;
+ }
+ return true;
+}
+
+function snapshotEntries(state: ClaudeTaskPlanState): AgentPlanEntry[] {
+ return Array.from(state.tasksById.values())
+ .sort((a, b) => a.order - b.order)
+ .map((task) => ({ content: task.subject, status: task.status, priority: "medium" as const }));
+}
+
+/**
+ * Layer 1 of 3 in the todo-plan dedup chain — the EMIT layer (claude only):
+ * the SDK translator feeds the same final tool input from several injection
+ * points (content_block stream delta, block stop, assistant fallback), so
+ * signature-compare here collapses them to one `plan` update. The downstream
+ * layers are NOT redundant: `AgentSession.applyCurrentTodoList` dedups the
+ * live snapshot's change notifications, and `planEntriesEqual`
+ * (AgentMessageStore) dedups the rendered plan message part.
+ */
+function emitIfChanged(
+ state: ClaudeTaskPlanState,
+ entries: AgentPlanEntry[]
+): SessionUpdate | null {
+ const signature = JSON.stringify(entries);
+ if (signature === state.lastSignature) return null;
+ state.lastSignature = signature;
+ return { sessionUpdate: "plan", entries };
+}
+
+function readTaskCreateResult(content: unknown): { id: string; subject?: string } | null {
+ const direct = readTaskShape(content);
+ if (direct) return direct;
+ if (typeof content === "string") {
+ return readTaskShape(tryParse(content)) ?? readTaskTextResult(content);
+ }
+ if (Array.isArray(content)) {
+ for (const block of content) {
+ if (!isRecord(block) || block.type !== "text" || typeof block.text !== "string") continue;
+ const parsed = readTaskShape(tryParse(block.text)) ?? readTaskTextResult(block.text);
+ if (parsed) return parsed;
+ }
+ }
+ return null;
+}
+
+// The task id is the `#N` ordinal — the same value TaskUpdate later sends as
+// `taskId`. We deliberately bind ONLY the id, not the echoed subject: the
+// authoritative subject is the tool_use's `input.subject` (already stashed in
+// the pending record), while this text is a localizable, possibly-truncated
+// echo. `readTaskCreateResult` falls back to the pending subject when this
+// returns no subject.
+const TASK_CREATED_RE = /^Task #(\d+) created successfully\b/;
+
+function readTaskTextResult(text: string): { id: string } | null {
+ const match = TASK_CREATED_RE.exec(text.trim());
+ return match ? { id: match[1] } : null;
+}
+
+function readTaskShape(value: unknown): { id: string; subject?: string } | null {
+ if (!isRecord(value) || !isRecord(value.task)) return null;
+ const { id, subject } = value.task;
+ if (typeof id !== "string" || id.length === 0) return null;
+ return { id, subject: typeof subject === "string" ? subject : undefined };
+}
+
+function tryParse(raw: string): unknown {
+ try {
+ return JSON.parse(raw);
+ } catch {
+ return null;
+ }
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
diff --git a/src/agentMode/sdk/sdkMessageTranslator.test.ts b/src/agentMode/sdk/sdkMessageTranslator.test.ts
index 830e49be..d4733889 100644
--- a/src/agentMode/sdk/sdkMessageTranslator.test.ts
+++ b/src/agentMode/sdk/sdkMessageTranslator.test.ts
@@ -585,3 +585,202 @@ describe("mapStopReason", () => {
expect(mapStopReason({ type: "result", subtype: "error_max_turns" } as any)).toBe("cancelled");
});
});
+
+describe("session todo-list normalization (TodoWrite / Task tools → plan)", () => {
+ function userMessage(content: unknown[], parent: string | null = null): SDKMessage {
+ return {
+ type: "user",
+ message: { content },
+ parent_tool_use_id: parent,
+ uuid: "uuid-u" as `${string}-${string}-${string}-${string}-${string}`,
+ session_id: SESSION_ID,
+ } as never;
+ }
+
+ it("emits a plan event alongside the tool events for a TodoWrite stream", () => {
+ const state = createTranslatorState();
+ translateSdkMessage(
+ streamEvent({
+ type: "content_block_start",
+ index: 0,
+ content_block: { type: "tool_use", id: "todo-1", name: "TodoWrite", input: {} },
+ }),
+ SESSION_ID,
+ state
+ );
+ const out = translateSdkMessage(
+ streamEvent({
+ type: "content_block_delta",
+ index: 0,
+ delta: {
+ type: "input_json_delta",
+ partial_json:
+ '{"todos":[{"content":"step A","status":"in_progress","activeForm":"Doing A"}]}',
+ },
+ }),
+ SESSION_ID,
+ state
+ );
+ expect(out).toHaveLength(2);
+ expect(out[1].update).toEqual({
+ sessionUpdate: "plan",
+ entries: [{ content: "step A", status: "in_progress", priority: "medium" }],
+ });
+ // The block-stop re-observation of the same final input must not re-emit.
+ const stop = translateSdkMessage(
+ streamEvent({ type: "content_block_stop", index: 0 }),
+ SESSION_ID,
+ state
+ );
+ expect(stop.filter((e) => e.update.sessionUpdate === "plan")).toHaveLength(0);
+ });
+
+ it("accumulates Task tools across messages: create → result id → update", () => {
+ const state = createTranslatorState();
+ translateSdkMessage(
+ streamEvent({
+ type: "content_block_start",
+ index: 0,
+ content_block: {
+ type: "tool_use",
+ id: "task-create-1",
+ name: "TaskCreate",
+ input: { subject: "Brainstorm imagery" },
+ },
+ }),
+ SESSION_ID,
+ state
+ );
+ const bound = translateSdkMessage(
+ userMessage([
+ {
+ type: "tool_result",
+ tool_use_id: "task-create-1",
+ // The real claude CLI string shape — id is the `#N` ordinal.
+ content: "Task #1 created successfully: Brainstorm imagery",
+ is_error: false,
+ },
+ ]),
+ SESSION_ID,
+ state
+ );
+ const boundPlan = bound.find((e) => e.update.sessionUpdate === "plan");
+ expect(boundPlan?.update).toEqual({
+ sessionUpdate: "plan",
+ entries: [{ content: "Brainstorm imagery", status: "pending", priority: "medium" }],
+ });
+
+ const updated = translateSdkMessage(
+ streamEvent({
+ type: "content_block_start",
+ index: 1,
+ content_block: {
+ type: "tool_use",
+ id: "task-upd-1",
+ name: "TaskUpdate",
+ input: { taskId: "1", status: "in_progress" },
+ },
+ }),
+ SESSION_ID,
+ state
+ );
+ const updatedPlan = updated.find((e) => e.update.sessionUpdate === "plan");
+ expect(updatedPlan?.update).toEqual({
+ sessionUpdate: "plan",
+ entries: [{ content: "Brainstorm imagery", status: "in_progress", priority: "medium" }],
+ });
+ });
+
+ it("ignores subagent calls (parent_tool_use_id set) and MCP tools sharing the name", () => {
+ const state = createTranslatorState();
+ const subagent = translateSdkMessage(
+ {
+ type: "stream_event",
+ event: {
+ type: "content_block_start",
+ index: 0,
+ content_block: {
+ type: "tool_use",
+ id: "sub-1",
+ name: "TodoWrite",
+ input: { todos: [{ content: "sub task", status: "pending" }] },
+ },
+ },
+ parent_tool_use_id: "parent-1",
+ uuid: "uuid-s" as `${string}-${string}-${string}-${string}-${string}`,
+ session_id: SESSION_ID,
+ } as never,
+ SESSION_ID,
+ state
+ );
+ expect(subagent.filter((e) => e.update.sessionUpdate === "plan")).toHaveLength(0);
+
+ const mcp = translateSdkMessage(
+ streamEvent({
+ type: "content_block_start",
+ index: 1,
+ content_block: {
+ type: "tool_use",
+ id: "mcp-1",
+ name: "mcp__tracker__TodoWrite",
+ input: { todos: [{ content: "mcp task", status: "pending" }] },
+ },
+ }),
+ SESSION_ID,
+ state
+ );
+ expect(mcp.filter((e) => e.update.sessionUpdate === "plan")).toHaveLength(0);
+ });
+
+ it("shares the accumulator across translator generations via createTranslatorState(shared)", () => {
+ const turn1 = createTranslatorState();
+ translateSdkMessage(
+ streamEvent({
+ type: "content_block_start",
+ index: 0,
+ content_block: {
+ type: "tool_use",
+ id: "c1",
+ name: "TaskCreate",
+ input: { subject: "persist me" },
+ },
+ }),
+ SESSION_ID,
+ turn1
+ );
+ translateSdkMessage(
+ userMessage([
+ {
+ type: "tool_result",
+ tool_use_id: "c1",
+ content: "Task #7 created successfully: persist me",
+ is_error: false,
+ },
+ ]),
+ SESSION_ID,
+ turn1
+ );
+
+ // Turn 2: fresh translator, same session-lived accumulator.
+ const turn2 = createTranslatorState(turn1.claudeTasks);
+ const out = translateSdkMessage(
+ streamEvent({
+ type: "content_block_start",
+ index: 0,
+ content_block: {
+ type: "tool_use",
+ id: "u1",
+ name: "TaskUpdate",
+ input: { taskId: "7", status: "completed" },
+ },
+ }),
+ SESSION_ID,
+ turn2
+ );
+ const plan = out.find((e) => e.update.sessionUpdate === "plan");
+ expect(plan?.update).toEqual({
+ sessionUpdate: "plan",
+ entries: [{ content: "persist me", status: "completed", priority: "medium" }],
+ });
+ });
+});
diff --git a/src/agentMode/sdk/sdkMessageTranslator.ts b/src/agentMode/sdk/sdkMessageTranslator.ts
index 140cd7e2..75470704 100644
--- a/src/agentMode/sdk/sdkMessageTranslator.ts
+++ b/src/agentMode/sdk/sdkMessageTranslator.ts
@@ -14,11 +14,19 @@ import type {
ToolCallContent,
} from "@/agentMode/session/types";
import { resolveToolName } from "@/agentMode/session/toolName";
+import {
+ createClaudeTaskPlanState,
+ planUpdateFromClaudeToolResult,
+ planUpdateFromClaudeToolUse,
+ type ClaudeTaskPlanState,
+} from "./claudeTodoPlan";
import { deriveToolKind, deriveToolTitle, vendorMetaFields } from "./toolMeta";
/**
* Mutable per-query translator state. One instance lives for the duration of
- * a single `query()` call; reset whenever a new turn starts.
+ * a single `query()` call; reset whenever a new turn starts — EXCEPT
+ * `claudeTasks`, which the caller shares across a session's queries (Task ids
+ * created in one turn must resolve when a later turn updates them).
*/
export interface TranslatorState {
toolUseBlocks: Map<
@@ -34,10 +42,16 @@ export interface TranslatorState {
>;
/** Tool-use ids already emitted in this turn — used to dedupe in the assistant-message fallback path. */
emittedToolUseIds: Set;
+ /** Session-lived todo/Task accumulator (see claudeTodoPlan.ts). */
+ claudeTasks: ClaudeTaskPlanState;
}
-export function createTranslatorState(): TranslatorState {
- return { toolUseBlocks: new Map(), emittedToolUseIds: new Set() };
+export function createTranslatorState(claudeTasks?: ClaudeTaskPlanState): TranslatorState {
+ return {
+ toolUseBlocks: new Map(),
+ emittedToolUseIds: new Set(),
+ claudeTasks: claudeTasks ?? createClaudeTaskPlanState(),
+ };
}
function event(sessionId: SessionId, update: SessionUpdate): SessionEvent {
@@ -137,6 +151,17 @@ function translateStreamEvent(
})
);
}
+ out.push(
+ ...todoPlanEvents(
+ sessionId,
+ state,
+ block.id,
+ name,
+ mcpServer,
+ parentToolUseId,
+ block.input ?? {}
+ )
+ );
return out;
}
return [];
@@ -179,6 +204,15 @@ function translateStreamEvent(
rawInput: parsed.value,
...vendorMetaFields(block.name, parentToolUseId, block.mcpServer),
}),
+ ...todoPlanEvents(
+ sessionId,
+ state,
+ block.id,
+ block.name,
+ block.mcpServer,
+ parentToolUseId,
+ parsed.value
+ ),
];
}
return [];
@@ -197,6 +231,15 @@ function translateStreamEvent(
status: "in_progress" as AgentToolStatus,
...vendorMetaFields(block.name, parentToolUseId, block.mcpServer),
}),
+ ...todoPlanEvents(
+ sessionId,
+ state,
+ block.id,
+ block.name,
+ block.mcpServer,
+ parentToolUseId,
+ finalInput
+ ),
];
}
case "message_delta":
@@ -221,14 +264,39 @@ function translateAssistantMessage(
if (state.emittedToolUseIds.has(b.id)) continue;
state.emittedToolUseIds.add(b.id);
out.push(event(sessionId, makeToolCallUpdate(b.id, b.name, b.input ?? {}, parentToolUseId)));
+ const { tool: name, mcpServer } = resolveToolName(b.name);
+ out.push(
+ ...todoPlanEvents(sessionId, state, b.id, name, mcpServer, parentToolUseId, b.input ?? {})
+ );
}
return out;
}
+/**
+ * Session todo-list normalization (claudeTodoPlan.ts): feed native, TOP-LEVEL
+ * TodoWrite / TaskCreate / TaskUpdate calls into the session accumulator and
+ * surface the resulting `plan` update. MCP tools sharing a name and subagent
+ * calls (`parent_tool_use_id` set) are excluded — a subagent's todos must not
+ * pollute the session-level Progress.
+ */
+function todoPlanEvents(
+ sessionId: SessionId,
+ state: TranslatorState,
+ toolUseId: string,
+ name: string,
+ mcpServer: string | undefined,
+ parentToolUseId: string | undefined,
+ rawInput: unknown
+): SessionEvent[] {
+ if (mcpServer || parentToolUseId) return [];
+ const update = planUpdateFromClaudeToolUse(state.claudeTasks, toolUseId, name, rawInput);
+ return update ? [event(sessionId, update)] : [];
+}
+
function translateUserMessage(
msg: SDKUserMessage,
sessionId: SessionId,
- _state: TranslatorState
+ state: TranslatorState
): SessionEvent[] {
const content = (msg.message as { content?: unknown }).content;
if (!Array.isArray(content)) return [];
@@ -251,6 +319,16 @@ function translateUserMessage(
content: outputs,
})
);
+ // A TaskCreate's result carries the task id; only ids pending in the
+ // accumulator match, so subagent results are inherently ignored. An
+ // is_error result still consumes its pending entry (passed as null content
+ // → no plan emitted) so failures can't accumulate over a long session.
+ const planUpdate = planUpdateFromClaudeToolResult(
+ state.claudeTasks,
+ b.tool_use_id,
+ b.is_error ? null : b.content
+ );
+ if (!b.is_error && planUpdate) out.push(event(sessionId, planUpdate));
}
return out;
}
diff --git a/src/agentMode/session/AgentChatBackend.ts b/src/agentMode/session/AgentChatBackend.ts
index 8491f558..ac605ce3 100644
--- a/src/agentMode/session/AgentChatBackend.ts
+++ b/src/agentMode/session/AgentChatBackend.ts
@@ -2,6 +2,7 @@ import type { MessageContext } from "@/types/message";
import type {
AgentChatMessage,
AgentQuestionAnswers,
+ AgentTodoListEntry,
AskUserQuestionPrompt,
BackendId,
BackendState,
@@ -86,6 +87,14 @@ export interface AgentChatBackend {
*/
getCurrentPlan(): CurrentPlan | null;
+ /**
+ * The session's live execution todo list (normalized from every backend's
+ * todo channel), or `null` when there is none. Live-only: a resumed session
+ * starts at `null` until the agent's next todo update. The project-info
+ * Progress section reads this.
+ */
+ getCurrentTodoList(): AgentTodoListEntry[] | null;
+
/**
* True when an ExitPlanMode permission is currently pending. The chat input
* disables itself while one is outstanding so the user is funneled to the
diff --git a/src/agentMode/session/AgentChatPersistenceManager.test.ts b/src/agentMode/session/AgentChatPersistenceManager.test.ts
index 0ca53d67..1b0dfb25 100644
--- a/src/agentMode/session/AgentChatPersistenceManager.test.ts
+++ b/src/agentMode/session/AgentChatPersistenceManager.test.ts
@@ -1,6 +1,7 @@
/* eslint-disable obsidianmd/no-tfile-tfolder-cast -- test fixtures; not real TFiles */
import { AI_SENDER, USER_SENDER } from "@/constants";
import { AgentChatPersistenceManager } from "./AgentChatPersistenceManager";
+import { GLOBAL_SCOPE } from "./scope";
import type { AgentChatMessage } from "./types";
import type { App, TFile } from "obsidian";
@@ -206,4 +207,79 @@ describe("AgentChatPersistenceManager", () => {
expect(result).toBeNull();
expect(app.files.size).toBe(0);
});
+
+ describe("projectId scope round-trip", () => {
+ it("round-trips a real projectId for a project-scoped chat", async () => {
+ const messages = [makeMessage(USER_SENDER, "hi")];
+ const saved = await manager.saveSession(messages, "claude", { projectId: "proj-123" });
+ expect(saved).not.toBeNull();
+
+ const raw = app.files.get(saved!.path)!.contents!;
+ expect(raw).toContain('projectId: "proj-123"');
+
+ const loaded = await manager.loadFile(app.files.get(saved!.path) as unknown as TFile);
+ expect(loaded.projectId).toBe("proj-123");
+ });
+
+ it("defaults an unscoped chat to GLOBAL_SCOPE and writes no projectId (hard contract)", async () => {
+ const messages = [makeMessage(USER_SENDER, "hi")];
+ const saved = await manager.saveSession(messages, "claude");
+ const raw = app.files.get(saved!.path)!.contents!;
+ expect(raw).not.toContain("projectId:");
+
+ const loaded = await manager.loadFile(app.files.get(saved!.path) as unknown as TFile);
+ expect(loaded.projectId).toBe(GLOBAL_SCOPE);
+ });
+
+ it("treats an explicit GLOBAL_SCOPE like an unscoped chat (no projectId frontmatter)", async () => {
+ const messages = [makeMessage(USER_SENDER, "hi")];
+ const saved = await manager.saveSession(messages, "claude", { projectId: GLOBAL_SCOPE });
+ const raw = app.files.get(saved!.path)!.contents!;
+ expect(raw).not.toContain("projectId:");
+
+ const loaded = await manager.loadFile(app.files.get(saved!.path) as unknown as TFile);
+ expect(loaded.projectId).toBe(GLOBAL_SCOPE);
+ });
+
+ it("treats a blank projectId option like an unscoped chat", async () => {
+ const messages = [makeMessage(USER_SENDER, "hi")];
+ const saved = await manager.saveSession(messages, "claude", { projectId: " " });
+ const raw = app.files.get(saved!.path)!.contents!;
+ expect(raw).not.toContain("projectId:");
+
+ const loaded = await manager.loadFile(app.files.get(saved!.path) as unknown as TFile);
+ expect(loaded.projectId).toBe(GLOBAL_SCOPE);
+ });
+
+ it("normalizes a padded projectId option before writing frontmatter", async () => {
+ const messages = [makeMessage(USER_SENDER, "hi")];
+ const saved = await manager.saveSession(messages, "claude", { projectId: " proj-123 " });
+ const raw = app.files.get(saved!.path)!.contents!;
+ expect(raw).toContain('projectId: "proj-123"');
+ expect(raw).not.toContain('projectId: " proj-123 "');
+
+ const loaded = await manager.loadFile(app.files.get(saved!.path) as unknown as TFile);
+ expect(loaded.projectId).toBe("proj-123");
+ });
+
+ it("maps a legacy agent__ chat with no projectId frontmatter to GLOBAL_SCOPE", async () => {
+ const path = "test-folder/agent__legacy.md";
+ await app.vault.adapter.write(
+ path,
+ [
+ "---",
+ "epoch: 1735732800000",
+ "mode: agent",
+ "backendId: claude",
+ "---",
+ "",
+ "**user**: hi",
+ ].join("\n")
+ );
+ // Reason: the stored file carries `contents`, so loadFile's vault.read
+ // path returns the frontmatter (a bare {path} fixture would read empty).
+ const loaded = await manager.loadFile(app.files.get(path) as unknown as TFile);
+ expect(loaded.projectId).toBe(GLOBAL_SCOPE);
+ });
+ });
});
diff --git a/src/agentMode/session/AgentChatPersistenceManager.ts b/src/agentMode/session/AgentChatPersistenceManager.ts
index 240b8571..847df394 100644
--- a/src/agentMode/session/AgentChatPersistenceManager.ts
+++ b/src/agentMode/session/AgentChatPersistenceManager.ts
@@ -20,6 +20,8 @@ import {
} from "@/utils/vaultAdapterUtils";
import { TFile, type App } from "obsidian";
import { Notice } from "obsidian";
+import { coerceProjectId, escapeYamlString, unescapeYamlString } from "./agentChatYaml";
+import { GLOBAL_SCOPE } from "./scope";
import type { AgentChatMessage, BackendId } from "./types";
const SAFE_FILENAME_BYTE_LIMIT = 100;
@@ -41,6 +43,14 @@ export interface LoadedAgentChat {
* resume was wired up — those fall back to a fresh session.
*/
sessionId?: string;
+ /**
+ * Scope this chat belongs to: a real project id, or {@link GLOBAL_SCOPE}.
+ * HARD CONTRACT: a chat with no `projectId` frontmatter (every legacy
+ * `agent__` chat) resolves to `GLOBAL_SCOPE`, so it keeps appearing in the
+ * global history. Never inferred from the filename — frontmatter is the only
+ * authority.
+ */
+ projectId: string;
}
interface ExistingMeta {
@@ -48,42 +58,7 @@ interface ExistingMeta {
label?: string;
lastAccessedAt?: number;
sessionId?: string;
-}
-
-/**
- * Escape a string for safe YAML double-quoted string value. Strips control
- * chars (including newlines) up front — a stray `\n` in the user's topic
- * would otherwise terminate the line and corrupt the rest of the frontmatter.
- */
-function escapeYamlString(str: string): string {
- return (
- str
- // eslint-disable-next-line no-control-regex
- .replace(/[\x00-\x1F\x7F]/g, " ")
- .replace(/\\/g, "\\\\")
- .replace(/"/g, '\\"')
- );
-}
-
-/**
- * Inverse of `escapeYamlString` for the values our hand-rolled frontmatter
- * parser extracts. Only handles the two escapes we emit (`\\` and `\"`).
- */
-function unescapeYamlString(str: string): string {
- let out = "";
- for (let i = 0; i < str.length; i++) {
- const c = str[i];
- if (c === "\\" && i + 1 < str.length) {
- const next = str[i + 1];
- if (next === "\\" || next === '"') {
- out += next;
- i++;
- continue;
- }
- }
- out += c;
- }
- return out;
+ projectId?: string;
}
/**
@@ -120,6 +95,13 @@ export class AgentChatPersistenceManager {
modelKey?: string;
existingPath?: string;
sessionId?: string | null;
+ /**
+ * Scope to record in frontmatter. Pass a real project id to bind the
+ * chat to that project; omit (or pass `GLOBAL_SCOPE`) for a global chat,
+ * which writes no `projectId` so it stays indistinguishable from legacy
+ * global chats.
+ */
+ projectId?: string;
}
): Promise<{ path: string } | null> {
if (messages.length === 0) return null;
@@ -149,6 +131,11 @@ export class AgentChatPersistenceManager {
modelKey: options?.modelKey,
lastAccessedAt: existingMeta.lastAccessedAt,
sessionId: options?.sessionId ?? existingMeta.sessionId,
+ // Reason: round-trip an existing chat's scope when the caller doesn't
+ // re-supply it (e.g. autosave updates), so a project chat never silently
+ // demotes itself to global on a later save. Coerce first so a blank
+ // option falls through to the existing scope instead of clobbering it.
+ projectId: coerceProjectId(options?.projectId) ?? existingMeta.projectId,
});
if (existingFile && isInVaultCache(this.app, existingFile.path)) {
@@ -215,12 +202,15 @@ export class AgentChatPersistenceManager {
const topic = frontmatter.topic?.trim() || undefined;
const label = frontmatter.agentLabel?.trim() || undefined;
const sessionId = frontmatter.sessionId?.trim() || undefined;
+ // HARD CONTRACT: absent/blank projectId → GLOBAL_SCOPE, so legacy `agent__`
+ // chats stay in the global history. Never inferred from the filename.
+ const projectId = frontmatter.projectId?.trim() || GLOBAL_SCOPE;
const messages = this.parseChatBody(body);
logInfo(
- `[AgentChatPersistenceManager] Loaded ${messages.length} messages from ${file.path} (backend=${backendId}, sessionId=${sessionId ?? "none"})`
+ `[AgentChatPersistenceManager] Loaded ${messages.length} messages from ${file.path} (backend=${backendId}, sessionId=${sessionId ?? "none"}, projectId=${projectId})`
);
- return { messages, backendId, topic, label, sessionId };
+ return { messages, backendId, topic, label, sessionId, projectId };
}
/**
@@ -268,6 +258,7 @@ export class AgentChatPersistenceManager {
lastAccessedAt:
typeof cached.lastAccessedAt === "number" ? cached.lastAccessedAt : undefined,
sessionId: typeof cached.sessionId === "string" ? cached.sessionId : undefined,
+ projectId: coerceProjectId(cached.projectId),
};
}
try {
@@ -279,6 +270,7 @@ export class AgentChatPersistenceManager {
label: fm.agentLabel,
lastAccessedAt: lastAccessed && Number.isFinite(lastAccessed) ? lastAccessed : undefined,
sessionId: typeof fm.sessionId === "string" ? fm.sessionId : undefined,
+ projectId: coerceProjectId(fm.projectId),
};
} catch {
return {};
@@ -459,6 +451,7 @@ export class AgentChatPersistenceManager {
modelKey?: string;
lastAccessedAt?: number;
sessionId?: string | null;
+ projectId?: string;
}): string {
const settings = getSettings();
const lines: string[] = [
@@ -467,6 +460,13 @@ export class AgentChatPersistenceManager {
`mode: ${AGENT_CHAT_MODE}`,
`backendId: ${args.backendId}`,
];
+ // Reason: global chats write no projectId, staying byte-identical to legacy
+ // `agent__` chats (which loadFile maps back to GLOBAL_SCOPE). Coerce so a
+ // blank/whitespace id never leaks a stray frontmatter line.
+ const projectId = coerceProjectId(args.projectId);
+ if (projectId && projectId !== GLOBAL_SCOPE) {
+ lines.push(`projectId: "${escapeYamlString(projectId)}"`);
+ }
if (args.sessionId) lines.push(`sessionId: "${escapeYamlString(args.sessionId)}"`);
if (args.topic) lines.push(`topic: "${escapeYamlString(args.topic)}"`);
if (args.label) lines.push(`agentLabel: "${escapeYamlString(args.label)}"`);
diff --git a/src/agentMode/session/AgentChatUIState.ts b/src/agentMode/session/AgentChatUIState.ts
index 0ef4d0cc..a09e41a9 100644
--- a/src/agentMode/session/AgentChatUIState.ts
+++ b/src/agentMode/session/AgentChatUIState.ts
@@ -4,6 +4,7 @@ import type { AgentSession } from "@/agentMode/session/AgentSession";
import type {
AgentChatMessage,
AgentQuestionAnswers,
+ AgentTodoListEntry,
AskUserQuestionPrompt,
BackendId,
BackendState,
@@ -34,6 +35,7 @@ export class AgentChatUIState implements AgentChatBackend {
onStatusChanged: () => this.notifyListeners(),
onModelChanged: () => this.notifyListeners(),
onCurrentPlanChanged: () => this.notifyListeners(),
+ onCurrentTodoListChanged: () => this.notifyListeners(),
});
}
@@ -152,6 +154,10 @@ export class AgentChatUIState implements AgentChatBackend {
return this.session.getCurrentPlan();
}
+ getCurrentTodoList(): AgentTodoListEntry[] | null {
+ return this.session.getCurrentTodoList();
+ }
+
async resolvePlanProposal(
proposalId: string,
decision: PlanDecisionAction,
diff --git a/src/agentMode/session/AgentMessageStore.ts b/src/agentMode/session/AgentMessageStore.ts
index d268059f..505e9933 100644
--- a/src/agentMode/session/AgentMessageStore.ts
+++ b/src/agentMode/session/AgentMessageStore.ts
@@ -94,7 +94,19 @@ function partsEqual(a: AgentMessagePart, b: AgentMessagePart): boolean {
}
}
-/** Compare plan entries without stringifying the whole part object. */
+/**
+ * Compare plan entries without stringifying the whole part object.
+ *
+ * Layer 3 of 3 in the todo-plan dedup chain — the RENDER layer: stops an
+ * unchanged plan part from re-triggering a React notify on `upsertAgentPart`.
+ * The other two layers guard different things and are NOT redundant with this:
+ * 1. emit layer (`claudeTodoPlan.emitIfChanged`): collapses the SDK
+ * translator's multiple injection points (stream delta / block stop /
+ * assistant fallback) that observe the same final tool input.
+ * 2. snapshot layer (`AgentSession.applyCurrentTodoList`): suppresses
+ * no-op `onCurrentTodoListChanged` ticks for the live Progress section.
+ * This layer is the only one that also dedups the plan MESSAGE part.
+ */
function planEntriesEqual(
a: Extract["entries"],
b: Extract["entries"]
diff --git a/src/agentMode/session/AgentSession.test.ts b/src/agentMode/session/AgentSession.test.ts
index 2e78ea5b..5de7d5d8 100644
--- a/src/agentMode/session/AgentSession.test.ts
+++ b/src/agentMode/session/AgentSession.test.ts
@@ -8,6 +8,7 @@ import {
withReadOnlyPreamble,
} from "./AgentSession";
import { ensureMultiAgentEntitlement, showMultiAgentUpgradePrompt } from "@/plusUtils";
+import { GLOBAL_SCOPE } from "./scope";
import { AuthRequiredError, MethodUnsupportedError } from "./errors";
import type { FanoutRunInput } from "./fanout/FanoutOrchestrator";
import { FANOUT_READONLY_PREAMBLE, type FanoutTurn } from "./fanout/fanoutTypes";
@@ -263,6 +264,30 @@ describe("buildPromptBlocks", () => {
expect(selectionPos).toBeLessThan(webTabPos);
expect(webTabPos).toBeLessThan(messagePos);
});
+
+ it("prepends the project-context block ahead of the attached context and message", () => {
+ const projectContextBlock =
+ "\n## Included folders\n- `/vault/Papers`\n";
+ const blocks = buildPromptBlocks(
+ "go",
+ { notes: [makeFile("a.md")], urls: [] },
+ undefined,
+ undefined,
+ projectContextBlock
+ );
+ const text = (blocks[0] as { type: "text"; text: string }).text;
+ expect(text).toContain("");
+ expect(text).toContain("`/vault/Papers`");
+ // Project context leads, then the per-message attached context, then the message.
+ expect(text.indexOf("")).toBeLessThan(text.indexOf(""));
+ expect(text.indexOf("")).toBeLessThan(text.indexOf(""));
+ });
+
+ it("omits the project-context block when none is provided (later turns)", () => {
+ const blocks = buildPromptBlocks("go", { notes: [makeFile("a.md")], urls: [] });
+ const text = (blocks[0] as { type: "text"; text: string }).text;
+ expect(text).not.toContain("");
+ });
});
describe("AgentSession.loadDisplayMessages", () => {
@@ -299,6 +324,31 @@ describe("AgentSession.loadDisplayMessages", () => {
});
});
+describe("AgentSession.projectId", () => {
+ it("defaults to GLOBAL_SCOPE when no projectId option is given", () => {
+ const mock = makeMockBackend();
+ const session = new AgentSession({
+ backend: mock.asBackend,
+ backendSessionId: "acp-1",
+ internalId: "internal-1",
+ backendId: "opencode",
+ });
+ expect(session.projectId).toBe(GLOBAL_SCOPE);
+ });
+
+ it("binds the provided projectId (immutable, like backendId)", () => {
+ const mock = makeMockBackend();
+ const session = new AgentSession({
+ backend: mock.asBackend,
+ backendSessionId: "acp-1",
+ internalId: "internal-1",
+ backendId: "opencode",
+ projectId: "proj-42",
+ });
+ expect(session.projectId).toBe("proj-42");
+ });
+});
+
describe("AgentSession.restoreLabel", () => {
function makeResumedSession(mock: ReturnType) {
return new AgentSession({
@@ -874,6 +924,46 @@ describe("AgentSession fan-out branching", () => {
// The live turn rides on the message itself for the UI.
expect(placeholder?.fanout?.summary.text).toBe("the narrative summary");
});
+
+ it("does not let a first-turn fan-out consume the visible session's project context", async () => {
+ const mock = makeMockBackend();
+ const runFanoutTurn = jest.fn(async (input: FanoutRunInput): Promise => {
+ const turn: FanoutTurn = {
+ answers: {
+ opencode: { backendId: "opencode", status: "done", text: "a" },
+ claude: { backendId: "claude", status: "done", text: "b" },
+ },
+ summary: { status: "done", text: "summary" },
+ };
+ input.onChange(turn);
+ return turn;
+ });
+ const session = new AgentSession({
+ backend: mock.asBackend,
+ backendSessionId: "acp-1",
+ internalId: "internal-1",
+ backendId: "opencode",
+ runFanoutTurn,
+ });
+ // Stand in for the block the initializer captures for the first prompt.
+ (session as unknown as { projectContextBlock: string | null }).projectContextBlock =
+ "\n## Included folders\n- `/vault/Papers`\n";
+
+ // Turn 1 is a fan-out: only the ephemeral sub-sessions receive the block,
+ // and the visible backend is never prompted.
+ await session.sendPrompt("review", undefined, undefined, ["opencode", "claude"]).turn;
+ const fanoutPrompt = runFanoutTurn.mock.calls[0][0].prompt[0] as { type: "text"; text: string };
+ expect(fanoutPrompt.text).toContain("");
+ expect(mock.prompt).not.toHaveBeenCalled();
+
+ // Turn 2 is a normal turn: the visible backend must finally receive the
+ // first-turn project context (regression — a fan-out turn used to burn it).
+ await session.sendPrompt("now you").turn;
+ expect(mock.prompt).toHaveBeenCalledTimes(1);
+ const req = mock.prompt.mock.calls[0][0] as { prompt: Array<{ type: string; text?: string }> };
+ const visibleText = (req.prompt[0] as { type: "text"; text: string }).text;
+ expect(visibleText).toContain("");
+ });
});
describe("AgentSession fan-out paywall (send-boundary entitlement)", () => {
@@ -1225,6 +1315,164 @@ describe("AgentSession.create (via start)", () => {
expect(session.getState()?.model).toBeNull();
});
+ it("passes the session's projectId into the newSession payload", async () => {
+ const mock = makeMockBackend();
+ mock.newSession.mockResolvedValueOnce({ sessionId: "acp-1", state: emptyState() });
+ const session = AgentSession.start({
+ backend: mock.asBackend,
+ cwd: "/vault/Projects/p",
+ internalId: "internal-1",
+ backendId: "opencode",
+ projectId: "proj-7",
+ });
+ await session.ready;
+ expect(mock.newSession).toHaveBeenCalledWith(
+ expect.objectContaining({ cwd: "/vault/Projects/p", projectId: "proj-7" })
+ );
+ });
+
+ it("defaults the newSession projectId to GLOBAL_SCOPE when no scope is given", async () => {
+ const mock = makeMockBackend();
+ mock.newSession.mockResolvedValueOnce({ sessionId: "acp-1", state: emptyState() });
+ const session = AgentSession.start({
+ backend: mock.asBackend,
+ cwd: "/vault",
+ internalId: "internal-1",
+ backendId: "opencode",
+ });
+ await session.ready;
+ expect(mock.newSession).toHaveBeenCalledWith(
+ expect.objectContaining({ projectId: GLOBAL_SCOPE })
+ );
+ });
+
+ it("awaits contextReady and forwards its roots into the newSession payload", async () => {
+ const mock = makeMockBackend();
+ mock.newSession.mockResolvedValueOnce({ sessionId: "acp-1", state: emptyState() });
+ const session = AgentSession.start({
+ backend: mock.asBackend,
+ cwd: "/vault/Projects/p",
+ internalId: "internal-1",
+ backendId: "opencode",
+ projectId: "proj-7",
+ contextReady: Promise.resolve({
+ additionalDirectories: ["/vault/SharedResearch"],
+ }),
+ });
+ await session.ready;
+ expect(mock.newSession).toHaveBeenCalledWith(
+ expect.objectContaining({ additionalDirectories: ["/vault/SharedResearch"] })
+ );
+ });
+
+ it("injects the block into the first user prompt only", async () => {
+ const mock = makeMockBackend();
+ mock.newSession.mockResolvedValueOnce({ sessionId: "acp-1", state: emptyState() });
+ const session = AgentSession.start({
+ backend: mock.asBackend,
+ cwd: "/vault/Projects/p",
+ internalId: "internal-1",
+ backendId: "opencode",
+ projectId: "proj-7",
+ contextReady: Promise.resolve({
+ additionalDirectories: [],
+ projectContextBlock:
+ "\n## Included folders\n- `/vault/Papers`\n",
+ }),
+ });
+ await session.ready;
+
+ await session.sendPrompt("first").turn;
+ await session.sendPrompt("second").turn;
+
+ const promptText = (call: number): string => {
+ const input = mock.prompt.mock.calls[call][0] as {
+ prompt: Array<{ type: string; text?: string }>;
+ };
+ return input.prompt.map((b) => b.text ?? "").join("\n");
+ };
+ expect(promptText(0)).toContain("");
+ expect(promptText(0)).toContain("`/vault/Papers`");
+ expect(promptText(0)).toContain("\nfirst\n");
+ // The block rides the first message only; the second turn is clean.
+ expect(promptText(1)).not.toContain("");
+ });
+
+ it("re-injects the block on retry when the first prompt fails", async () => {
+ const mock = makeMockBackend();
+ mock.newSession.mockResolvedValueOnce({ sessionId: "acp-1", state: emptyState() });
+ // First delivery fails hard (transport) before the backend accepts the turn.
+ mock.prompt.mockRejectedValueOnce(new Error("transport down"));
+ const session = AgentSession.start({
+ backend: mock.asBackend,
+ cwd: "/vault/Projects/p",
+ internalId: "internal-1",
+ backendId: "opencode",
+ projectId: "proj-7",
+ contextReady: Promise.resolve({
+ additionalDirectories: [],
+ projectContextBlock:
+ "\n## Included folders\n- `/vault/Papers`\n",
+ }),
+ });
+ await session.ready;
+
+ await expect(session.sendPrompt("first").turn).rejects.toThrow("transport down");
+ await session.sendPrompt("retry").turn;
+
+ const promptText = (call: number): string => {
+ const input = mock.prompt.mock.calls[call][0] as {
+ prompt: Array<{ type: string; text?: string }>;
+ };
+ return input.prompt.map((b) => b.text ?? "").join("\n");
+ };
+ // The failed attempt carried it; since the backend never accepted the turn,
+ // the retry carries it again rather than dropping the context permanently.
+ expect(promptText(0)).toContain("");
+ expect(promptText(1)).toContain("");
+ });
+
+ it("forwards an empty roots array when no contextReady is supplied", async () => {
+ const mock = makeMockBackend();
+ mock.newSession.mockResolvedValueOnce({ sessionId: "acp-1", state: emptyState() });
+ const session = AgentSession.start({
+ backend: mock.asBackend,
+ cwd: "/vault",
+ internalId: "internal-1",
+ backendId: "opencode",
+ });
+ await session.ready;
+ expect(mock.newSession).toHaveBeenCalledWith(
+ expect.objectContaining({ additionalDirectories: [] })
+ );
+ });
+
+ it("does not call newSession until contextReady resolves (non-blocking visibility)", async () => {
+ const mock = makeMockBackend();
+ mock.newSession.mockResolvedValueOnce({ sessionId: "acp-1", state: emptyState() });
+ let release!: (result: { additionalDirectories: string[] }) => void;
+ const contextReady = new Promise<{ additionalDirectories: string[] }>((resolve) => {
+ release = resolve;
+ });
+ const session = AgentSession.start({
+ backend: mock.asBackend,
+ cwd: "/vault/Projects/p",
+ internalId: "internal-1",
+ backendId: "opencode",
+ projectId: "proj-7",
+ contextReady,
+ });
+ // Session exists immediately; newSession is gated on contextReady.
+ await Promise.resolve();
+ expect(mock.newSession).not.toHaveBeenCalled();
+ expect(session.getStatus()).toBe("starting");
+ release({ additionalDirectories: ["/vault/SharedResearch"] });
+ await session.ready;
+ expect(mock.newSession).toHaveBeenCalledWith(
+ expect.objectContaining({ additionalDirectories: ["/vault/SharedResearch"] })
+ );
+ });
+
it("attempts setModel when defaultModelSelection is set", async () => {
const mock = makeMockBackend();
const stateWithSonnet: BackendState = {
@@ -3224,3 +3472,74 @@ describe("AgentSession streamed-token notification coalescing", () => {
expect(placeholder?.turnStopReason).toBe("end_turn");
});
});
+
+describe("AgentSession.getCurrentTodoList", () => {
+ function makeSession(mock: ReturnType) {
+ return new AgentSession({
+ backend: mock.asBackend,
+ backendSessionId: "acp-1",
+ internalId: "internal-1",
+ backendId: "opencode",
+ });
+ }
+ const planUpdate = (entries: { content: string; status: string; priority?: string }[]) => ({
+ sessionId: "acp-1",
+ update: { sessionUpdate: "plan", entries } as never,
+ });
+
+ it("starts null, snapshots plan entries, and notifies the dedicated channel", () => {
+ const mock = makeMockBackend();
+ const session = makeSession(mock);
+ expect(session.getCurrentTodoList()).toBeNull();
+
+ let notified = 0;
+ session.subscribe({
+ onMessagesChanged: () => {},
+ onStatusChanged: () => {},
+ onCurrentTodoListChanged: () => notified++,
+ });
+ mock.emit(
+ planUpdate([
+ { content: "step A", status: "in_progress", priority: "medium" },
+ { content: "step B", status: "pending", priority: "medium" },
+ ])
+ );
+ expect(notified).toBe(1);
+ expect(session.getCurrentTodoList()).toEqual([
+ { content: "step A", status: "in_progress" },
+ { content: "step B", status: "pending" },
+ ]);
+ });
+
+ it("dedupes identical lists (synthesized + real plan channel) by content signature", () => {
+ const mock = makeMockBackend();
+ const session = makeSession(mock);
+ let notified = 0;
+ session.subscribe({
+ onMessagesChanged: () => {},
+ onStatusChanged: () => {},
+ onCurrentTodoListChanged: () => notified++,
+ });
+ const entries = [{ content: "same", status: "pending", priority: "high" }];
+ mock.emit(planUpdate(entries));
+ const snapshot = session.getCurrentTodoList();
+ mock.emit(planUpdate([{ content: "same", status: "pending", priority: "low" }]));
+ expect(notified).toBe(1);
+ // Held reference stays stable across the deduped update.
+ expect(session.getCurrentTodoList()).toBe(snapshot);
+ });
+
+ it("clears to null on an empty plan and on dispose", async () => {
+ const mock = makeMockBackend();
+ const session = makeSession(mock);
+ mock.emit(planUpdate([{ content: "only", status: "pending" }]));
+ expect(session.getCurrentTodoList()).not.toBeNull();
+
+ mock.emit(planUpdate([]));
+ expect(session.getCurrentTodoList()).toBeNull();
+
+ mock.emit(planUpdate([{ content: "again", status: "pending" }]));
+ await session.dispose();
+ expect(session.getCurrentTodoList()).toBeNull();
+ });
+});
diff --git a/src/agentMode/session/AgentSession.ts b/src/agentMode/session/AgentSession.ts
index 9994f983..1f6cc8aa 100644
--- a/src/agentMode/session/AgentSession.ts
+++ b/src/agentMode/session/AgentSession.ts
@@ -1,10 +1,13 @@
import { AI_SENDER, USER_SENDER, WEB_SELECTED_TEXT_TAG } from "@/constants";
import { logInfo, logWarn } from "@/logger";
import { AgentMessageStore } from "@/agentMode/session/AgentMessageStore";
+import { GLOBAL_SCOPE, type ProjectScopeId } from "@/agentMode/session/scope";
import {
AgentChatMessage,
AgentMessagePart,
+ AgentPlanEntry,
AgentQuestionAnswers,
+ AgentTodoListEntry,
AgentToolCallOutput,
AskUserQuestionPrompt,
BackendDescriptor,
@@ -42,6 +45,7 @@ import { resolveMcpServers } from "@/agentMode/session/mcpResolver";
import { deriveChatTitleFromMessages } from "@/agentMode/session/chatHistoryMerge";
import { getSettings } from "@/settings/model";
import { ContextProcessor } from "@/contextProcessor";
+import type { ContextMaterializationResult } from "@/context/projectContextMaterializer";
import { escapeXml } from "@/LLMProviders/chainRunner/utils/xmlParsing";
import type { FanoutRunInput } from "@/agentMode/session/fanout/FanoutOrchestrator";
import { isFanout } from "@/agentMode/session/fanout/answerers";
@@ -90,6 +94,9 @@ const EMPTY_QUESTIONS: AskUserQuestionPrompt[] = [];
const EMPTY_ANSWERS: AgentQuestionAnswers = Object.freeze({});
// Canonical "no fan-out" selection — referential stability on the single-agent path.
const EMPTY_BACKEND_IDS: ReadonlyArray = Object.freeze([]);
+// Shared "no extra roots" array so a session created without project context
+// keeps a stable reference (no fresh `[]` allocation per construction).
+const EMPTY_ADDITIONAL_DIRECTORIES: string[] = Object.freeze([]) as unknown as string[];
/**
* Optimistically swap `state.model.current.baseModelId` for the persisted
@@ -159,6 +166,12 @@ export interface AgentSessionListener {
* subscribe to this channel.
*/
onCurrentPlanChanged?(): void;
+ /**
+ * Optional: fired when the live execution todo list changes (a backend
+ * `plan` update with different content arrived, or the session reset).
+ * The project-info Progress section subscribes to this channel.
+ */
+ onCurrentTodoListChanged?(): void;
/**
* Optional: fired when the "needs attention" flag flips. The tab strip
* subscribes to render an accent dot on the brand icon for backgrounded
@@ -173,6 +186,11 @@ export interface AgentSessionStartOptions {
cwd: string;
internalId: string;
backendId: BackendId;
+ /**
+ * Scope this session belongs to. Immutable like `backendId`; defaults to
+ * {@link GLOBAL_SCOPE} (the implicit global workspace).
+ */
+ projectId?: ProjectScopeId;
/**
* Persisted user preference to apply after the backend's initial session
* state. The session seeds it optimistically so the first picker paint
@@ -210,6 +228,19 @@ export interface AgentSessionStartOptions {
* never reaches for the global `app`. Manager-supplied; tests omit it.
*/
getApp?: () => App;
+ /**
+ * Resolves to the project's context-materialization result. Supplied by the
+ * manager and awaited in `initialize` right BEFORE `newSession`, so the
+ * session appears immediately (send-gated by the loading card) while prefetch
+ * runs in the background and the backend still gets the roots once they're
+ * ready. Absent for GLOBAL / context-free sessions — `initialize` then opens
+ * without delay.
+ *
+ * Carries both the extra searchable roots (forwarded to `newSession`) and the
+ * optional inline `` block, captured for injection into this
+ * session's first user prompt.
+ */
+ contextReady?: Promise;
}
/**
@@ -221,6 +252,8 @@ export interface AgentSessionStateOptions {
backendSessionId: SessionId;
internalId: string;
backendId: BackendId;
+ /** Scope this session belongs to. See {@link AgentSessionStartOptions.projectId}. */
+ projectId?: ProjectScopeId;
initialState?: BackendState | null;
/**
* Optional persisted user preference applied to the warm/adopted session.
@@ -252,11 +285,24 @@ export class AgentSession {
readonly store = new AgentMessageStore();
readonly internalId: string;
readonly backendId: BackendId;
+ /** Immutable scope binding ({@link GLOBAL_SCOPE} or a project id). */
+ readonly projectId: ProjectScopeId;
/** Resolves when `newSession` succeeds; rejects when it fails. */
readonly ready: Promise;
private backendSessionId: SessionId | null = null;
private readonly backend: BackendProcess;
private readonly cwd: string | null;
+ // Resolves to the project's context-materialization result; awaited before
+ // `newSession` (null for context-free / resumed sessions, which open without
+ // delay).
+ private readonly contextReady: Promise | null;
+ // The project's `` block, captured from `contextReady` in
+ // `initialize`. Inlined into the FIRST user prompt only (see `runTurn` /
+ // `buildPromptBlocks`); null for GLOBAL / context-free / resumed sessions.
+ private projectContextBlock: string | null = null;
+ // Flips true once the first user prompt has been built, so the project-context
+ // block is injected exactly once at the head of the conversation.
+ private firstPromptSent = false;
private readonly getDescriptor: (() => BackendDescriptor | undefined) | null;
private readonly runFanoutTurn: RunFanoutTurn | null;
private readonly getDisplayName: ((backendId: BackendId) => string) | null;
@@ -343,6 +389,15 @@ export class AgentSession {
// while in canonical plan mode and a plan has been proposed; cleared on a
// terminal user decision or when the canonical mode flips out of plan.
private currentPlan: CurrentPlan | null = null;
+ // Live execution todo list — the latest `plan` update's entries, normalized
+ // for consumers (the trail's PlanPill reads the message part instead; this
+ // snapshot feeds surfaces outside the message flow). LIVE-ONLY by design:
+ // chat persistence drops plan parts, so a resumed/reloaded session starts
+ // at null and repopulates on the agent's next todo update.
+ private currentTodoList: AgentTodoListEntry[] | null = null;
+ // Signature of the last applied list — multiple equal plan updates (e.g.
+ // opencode's synthesized + occasional real plan channel) must not re-notify.
+ private currentTodoListSignature: string | null = null;
// Monotonic counter for `currentPlan.id` so the React tree can detect a
// *new* plan-mode review (vs. an in-place revision that bumps `revision`).
private planSeq = 0;
@@ -366,11 +421,15 @@ export class AgentSession {
this.backend = opts.backend;
this.internalId = opts.internalId;
this.backendId = opts.backendId;
+ this.projectId = opts.projectId ?? GLOBAL_SCOPE;
this.cwd = opts.cwd ?? null;
this.getDescriptor = opts.getDescriptor ?? null;
this.runFanoutTurn = opts.runFanoutTurn ?? null;
this.getDisplayName = opts.getDisplayName ?? null;
this.getApp = opts.getApp ?? null;
+ // Only the start path (newSession) awaits context roots; adopted/resumed
+ // sessions had theirs forwarded by the manager's resume/load call already.
+ this.contextReady = "contextReady" in opts ? (opts.contextReady ?? null) : null;
if ("backendSessionId" in opts) {
this.backendSessionId = opts.backendSessionId;
const originalState = opts.initialState ?? null;
@@ -419,9 +478,27 @@ export class AgentSession {
private async initialize(opts: AgentSessionStartOptions): Promise {
const { backend, cwd, defaultModelSelection } = opts;
try {
+ // Await the project's context roots (if any) BEFORE opening the session.
+ // The session is already visible and send-gated by the loading card; this
+ // delay only postpones the backend round-trip until prefetch settles. The
+ // promise never rejects (degrades to empty), so it can't strand startup.
+ const contextResult = this.contextReady ? await this.contextReady : null;
+ const additionalDirectories =
+ contextResult?.additionalDirectories ?? EMPTY_ADDITIONAL_DIRECTORIES;
+ // Capture the inline `` block for this session's first
+ // prompt. The roots go to `newSession` below; the block rides the first
+ // user message (see `runTurn`).
+ this.projectContextBlock = contextResult?.projectContextBlock ?? null;
+ if (this.disposed) return;
const resp = await backend.newSession({
cwd,
mcpServers: resolveMcpServers(backend, getSettings().agentMode?.mcpServers),
+ // Capture the owning scope alongside cwd so the backend can resolve
+ // this project's instructions; GLOBAL_SCOPE for the global workspace.
+ projectId: this.projectId,
+ // Extra searchable roots from the project's materialized context. The
+ // backend honors them only when it advertises the capability.
+ additionalDirectories,
});
if (this.disposed) return;
const modelLog = resp.state.model
@@ -891,6 +968,10 @@ export class AgentSession {
// synchronously within this turn (callers rely on that timing).
const hasWebTabs = (context?.webTabs?.length ?? 0) > 0;
const webTabBlock = hasWebTabs ? await serializeWebTabContext(context) : "";
+ // The project-context block rides the FIRST user prompt only; capture it
+ // up front so both the fan-out and single-agent paths can inject it.
+ const isFirstTurn = !this.firstPromptSent;
+ const projectContextBlock = isFirstTurn ? this.projectContextBlock : null;
// Fan-out path: the `@`-mentioned answerers dispatch the identical prompt in
// parallel ephemeral read-only sub-sessions and the main agent summarizes.
@@ -922,8 +1003,15 @@ export class AgentSession {
context,
promptContent,
webTabBlock,
+ projectContextBlock,
historyBlock
);
+ // Deliberately do NOT mark `firstPromptSent` here. Fan-out delivers the
+ // first-turn project-context block only to the ephemeral sub-sessions; the
+ // visible backend never receives this prompt. Consuming the flag would make
+ // the next normal turn skip the block, permanently stripping the project
+ // manifest from the main chat. Leaving it unset lets that turn deliver it
+ // (and each ephemeral fan-out, being memoryless, re-receives it meanwhile).
return await this.runFanoutPath(placeholderId, displayText, promptBlocks, turnStartedAt);
}
@@ -937,6 +1025,7 @@ export class AgentSession {
context,
promptContent,
webTabBlock,
+ projectContextBlock,
leadingContextBlock
);
@@ -952,6 +1041,11 @@ export class AgentSession {
if (leadingContextBlock !== null && resp.stopReason !== "cancelled") {
this.pendingFanoutContext = [];
}
+ // Mark the project-context block delivered only once the backend has accepted
+ // the turn, so a hard `prompt()` failure (transport/auth) leaves the flag
+ // unset and the user's retry re-delivers the context (a user cancel still
+ // resolves here, and the prompt did reach the backend, so it counts as delivered).
+ if (isFirstTurn) this.firstPromptSent = true;
if (
placeholderId &&
resp.stopReason !== "cancelled" &&
@@ -1157,6 +1251,8 @@ export class AgentSession {
this.flushQuestionResolvers();
this.decidedPlanToolCallIds.clear();
this.currentPlan = null;
+ this.currentTodoList = null;
+ this.currentTodoListSignature = null;
this.settledStream = null;
this.currentMessageIds = new Set();
// Fire the `"closed"` transition before clearing listeners so
@@ -1217,6 +1313,16 @@ export class AgentSession {
return this.currentPlan;
}
+ /**
+ * The live execution todo list, or `null` when the session has none (no
+ * update yet, the agent cleared it, or the session was resumed — the
+ * snapshot is live-only; persistence never stores it). Returns the held
+ * array reference so React subscribers don't tear on unrelated ticks.
+ */
+ getCurrentTodoList(): AgentTodoListEntry[] | null {
+ return this.currentTodoList;
+ }
+
/**
* Drop the current plan once the user has decided. The UI gates the card
* render on `decision === "pending"`, so a terminal state is never visible
@@ -1426,6 +1532,12 @@ export class AgentSession {
private handleSessionEvent(event: SessionEvent): void {
const update = event.update;
+ // Refresh the live todo snapshot before any placeholder gating — the
+ // snapshot is session-scoped state, not part of the message trail.
+ if (update.sessionUpdate === "plan" && this.applyCurrentTodoList(update.entries)) {
+ this.notifyCurrentTodoListChanged();
+ }
+
// Session-scoped updates aren't tied to a turn placeholder.
if (update.sessionUpdate === "session_info_update") {
// Only trust a pushed title from a backend that actually summarizes
@@ -1682,6 +1794,43 @@ export class AgentSession {
}
}
+ /**
+ * Apply a `plan` update's entries to the live todo snapshot. Returns true
+ * when the canonical content actually changed (signature compare) — equal
+ * lists from redundant updates (opencode's synthesized + real plan channel,
+ * Claude's multiple stream injection points) are dropped silently. An empty
+ * entries list clears the snapshot back to `null`.
+ *
+ * Layer 2 of 3 in the todo-plan dedup chain — the SNAPSHOT layer: suppresses
+ * no-op `onCurrentTodoListChanged` ticks for the Progress section. It is NOT
+ * redundant with the others: the emit layer (`claudeTodoPlan.emitIfChanged`)
+ * collapses one backend's repeated injections, and `planEntriesEqual`
+ * (AgentMessageStore) dedups the rendered plan message part — this layer is
+ * the only one guarding the live snapshot's listeners, and the only one that
+ * sees ALL backends' plan updates converged.
+ */
+ private applyCurrentTodoList(entries: AgentPlanEntry[]): boolean {
+ const next: AgentTodoListEntry[] = entries.map((e) => ({
+ content: e.content,
+ status: e.status,
+ }));
+ const signature = next.length > 0 ? JSON.stringify(next) : null;
+ if (signature === this.currentTodoListSignature) return false;
+ this.currentTodoList = next.length > 0 ? next : null;
+ this.currentTodoListSignature = signature;
+ return true;
+ }
+
+ private notifyCurrentTodoListChanged(): void {
+ for (const l of this.listeners) {
+ try {
+ l.onCurrentTodoListChanged?.();
+ } catch (e) {
+ logWarn(`[AgentMode] todo-list listener threw`, e);
+ }
+ }
+ }
+
private notifyCurrentPlanChanged(): void {
for (const l of this.listeners) {
try {
@@ -1810,13 +1959,17 @@ export function buildPromptBlocks(
context?: MessageContext,
content?: PromptContent[],
webTabBlock?: string,
+ projectContextBlock?: string | null,
leadingContextBlock?: string | null
): PromptContent[] {
- // Context sections precede the user message: an optional prior-turn block
- // (buffered fan-out turns the backend never saw), the vault envelope, web-
- // selection excerpts, then live web-tab content. Web blocks reuse the legacy
- // `` tags. `leadingContextBlock` is absent on the common path.
+ // Context sections precede the user message: the project-context block (first
+ // user prompt only — the project's folders/notes/URLs), then an optional
+ // prior-turn block (buffered fan-out turns the backend never saw), the vault
+ // envelope (attached notes + note excerpts), web-selection excerpts, then live
+ // web-tab content. Web tab/selection blocks reuse the legacy `` tags so
+ // the model reads the same shapes it does in the non-agent chat.
const sections = [
+ projectContextBlock?.trim() || null,
leadingContextBlock?.trim() || null,
buildContextEnvelope(context),
buildWebSelectionBlocks(context),
@@ -1878,8 +2031,10 @@ function buildWebSelectionBlocks(context: MessageContext | undefined): string |
}
/**
- * Build the `` envelope listing attached vault paths and
- * inlining note excerpts. Returns `null` when there's nothing to attach.
+ * Build the `` envelope listing the vault items attached to
+ * THIS message (`@notes` + selected excerpts) and inlining note excerpts.
+ * Returns `null` when there's nothing to attach. Distinct from the project-wide
+ * `` block, which lists the project's configured sources.
*/
function buildContextEnvelope(context: MessageContext | undefined): string | null {
if (!context) return null;
diff --git a/src/agentMode/session/AgentSessionIndex.test.ts b/src/agentMode/session/AgentSessionIndex.test.ts
index 4f1c41ea..01d4ce70 100644
--- a/src/agentMode/session/AgentSessionIndex.test.ts
+++ b/src/agentMode/session/AgentSessionIndex.test.ts
@@ -155,6 +155,36 @@ describe("AgentSessionIndex", () => {
expect((await second.getEntry("opencode", "s1"))?.title).toBe("My rename");
});
+ it("scopes entries to a project; sweeps fill a missing scope but never strip a known one", async () => {
+ // Reason: project views filter native entries by the recorded projectId.
+ // Write-through (live sessions) is authoritative; a `listSessions` sweep
+ // re-discovering the same session arrives without (or with weaker)
+ // attribution and must not detach the chat from its project.
+ const index = new AgentSessionIndex(makeStorage(), INDEX_PATH);
+ await index.recordSession(entry({ projectId: "proj-1" }));
+ await index.mergeDiscoveredSessions([entry({ title: "Sweep title", lastAccessedAtMs: 9_000 })]);
+ expect((await index.getEntry("opencode", "s1"))?.projectId).toBe("proj-1");
+
+ // A sweep CAN attribute a session the index never saw live (CLI-created
+ // inside a project folder).
+ await index.mergeDiscoveredSessions([
+ entry({ sessionId: "s2", projectId: "proj-2", lastAccessedAtMs: 9_000 }),
+ ]);
+ expect((await index.getEntry("opencode", "s2"))?.projectId).toBe("proj-2");
+ });
+
+ it("the project scope round-trips through persistence", async () => {
+ const storage = makeStorage();
+ const first = new AgentSessionIndex(storage, INDEX_PATH);
+ await first.recordSession(entry({ projectId: "proj-1" }));
+ await first.recordSession(entry({ sessionId: "global-chat" }));
+ await first.flush();
+ const second = new AgentSessionIndex(storage, INDEX_PATH);
+ expect((await second.getEntry("opencode", "s1"))?.projectId).toBe("proj-1");
+ // Absent scope (a pre-projectId or global entry) stays absent ≙ global.
+ expect((await second.getEntry("opencode", "global-chat"))?.projectId).toBeUndefined();
+ });
+
it("persists across instances via flush and ignores corrupt files", async () => {
const storage = makeStorage();
const first = new AgentSessionIndex(storage, INDEX_PATH);
diff --git a/src/agentMode/session/AgentSessionIndex.ts b/src/agentMode/session/AgentSessionIndex.ts
index e8dfae09..9f4b2149 100644
--- a/src/agentMode/session/AgentSessionIndex.ts
+++ b/src/agentMode/session/AgentSessionIndex.ts
@@ -23,6 +23,15 @@ export interface AgentSessionIndexEntry {
titleSource?: "user" | "agent";
createdAtMs: number;
lastAccessedAtMs: number;
+ /**
+ * Owning Agent Projects scope. Absent ≙ the global workspace — including
+ * every entry written before this field existed, so old index files keep
+ * working without a version bump. The project id (not the folder path) is
+ * stored because ids survive a project folder rename; live write-through is
+ * the authoritative source (each `AgentSession` is scope-immutable), with
+ * native sweeps falling back to cwd→project-folder attribution.
+ */
+ projectId?: string;
}
interface AgentSessionIndexFile {
@@ -76,6 +85,7 @@ function sanitizeEntry(raw: unknown): AgentSessionIndexEntry | null {
title && (r.titleSource === "user" || r.titleSource === "agent") ? r.titleSource : undefined,
createdAtMs: createdAtMs ?? lastAccessedAtMs!,
lastAccessedAtMs: lastAccessedAtMs ?? createdAtMs!,
+ projectId: typeof r.projectId === "string" && r.projectId.trim() ? r.projectId : undefined,
};
}
@@ -134,6 +144,10 @@ export class AgentSessionIndex {
entry.lastAccessedAtMs,
existing?.lastAccessedAtMs ?? entry.lastAccessedAtMs
),
+ // A session's scope is immutable, so live and recorded values can only
+ // agree — keep whichever side knows it (an absent incoming value must
+ // not strip a previously recorded scope).
+ projectId: entry.projectId ?? existing?.projectId,
});
this.scheduleSave();
}
@@ -163,12 +177,16 @@ export class AgentSessionIndex {
entry.lastAccessedAtMs,
existing?.lastAccessedAtMs ?? entry.lastAccessedAtMs
),
+ // Write-through knows the scope authoritatively; a sweep's cwd-derived
+ // attribution only fills gaps, never overrides.
+ projectId: existing?.projectId ?? entry.projectId,
};
if (
!existing ||
existing.title !== next.title ||
existing.createdAtMs !== next.createdAtMs ||
- existing.lastAccessedAtMs !== next.lastAccessedAtMs
+ existing.lastAccessedAtMs !== next.lastAccessedAtMs ||
+ existing.projectId !== next.projectId
) {
this.entries.set(key, next);
changed = true;
diff --git a/src/agentMode/session/AgentSessionManager.test.ts b/src/agentMode/session/AgentSessionManager.test.ts
index bd1b1a34..993796ce 100644
--- a/src/agentMode/session/AgentSessionManager.test.ts
+++ b/src/agentMode/session/AgentSessionManager.test.ts
@@ -8,12 +8,28 @@ import { AgentSession } from "./AgentSession";
import { buildNativeChatId } from "@/utils/nativeChatId";
import { AgentSessionIndex } from "./AgentSessionIndex";
import { AgentSessionManager } from "./AgentSessionManager";
+import { GLOBAL_SCOPE } from "./scope";
import {
getSettings as mockedGetSettings,
setSettings as mockedSetSettings,
} from "@/settings/model";
+import * as projectsState from "@/projects/state";
+import {
+ ensureProjectContextMaterialized,
+ type ContextMaterializeProgress,
+} from "@/context/projectContextMaterializer";
+import { ProjectFileManager } from "@/projects/ProjectFileManager";
+import {
+ agentProjectContextLoadAtom,
+ type AgentProjectContextLoadState,
+ type ProjectConfig,
+} from "@/aiParams";
+import type { ProjectFileRecord } from "@/projects/type";
+import { getProjectContextSignature } from "@/projects/projectContextSignature";
import type { BackendDescriptor } from "./types";
+const mockEnsureMaterialized = ensureProjectContextMaterialized as jest.Mock;
+
jest.mock("@/logger", () => ({
logInfo: jest.fn(),
logWarn: jest.fn(),
@@ -26,6 +42,39 @@ const settingsChangeCallbacks = new Set<
(prev: { agentMode: unknown }, next: { agentMode: unknown }) => void
>();
+// Stub the project-folder mirror so a non-global spawn never touches the vault.
+jest.mock("@/projects/ensureAgentsMirror", () => ({
+ ensureAgentsMirror: jest.fn(async () => undefined),
+}));
+
+// MRU touch is fire-and-forget through the singleton; mock it so enterProject
+// tests assert the call without real frontmatter IO.
+const mockTouchProjectLastUsed = jest.fn(async () => undefined);
+jest.mock("@/projects/ProjectFileManager", () => ({
+ ProjectFileManager: {
+ getInstance: jest.fn(() => ({ touchProjectLastUsed: mockTouchProjectLastUsed })),
+ },
+}));
+
+// Stub context materialization so a non-global create / background warm never
+// hits brevilabs or the disk. The result faithfully carries the CAPTURED
+// signature of the live record (what the real materializer returns), so the
+// dirty-clear path behaves realistically; a spy so warm calls can be asserted.
+jest.mock("@/context/projectContextMaterializer", () => {
+ const { getProjectContextSignature } = jest.requireActual("@/projects/projectContextSignature");
+ const { getCachedProjectRecordById } = jest.requireActual("@/projects/state");
+ return {
+ ensureProjectContextMaterialized: jest.fn(async (_app: unknown, projectId: string) => {
+ const record = getCachedProjectRecordById(projectId);
+ return {
+ additionalDirectories: [],
+ contextSignature: record ? getProjectContextSignature(record) : undefined,
+ };
+ }),
+ EMPTY_CONTEXT_MATERIALIZATION_RESULT: { additionalDirectories: [] },
+ };
+});
+
jest.mock("@/settings/model", () => ({
getSettings: jest.fn(() => ({
agentMode: { activeBackend: "opencode", backends: {} },
@@ -37,6 +86,10 @@ jest.mock("@/settings/model", () => ({
return () => settingsChangeCallbacks.delete(cb);
}
),
+ // Minimal jotai-store shim: a non-global spawn publishes context-load state
+ // through it (beginContextMaterialization). `get` returns an empty map so the
+ // first publish "owns" the flight; `set` is a no-op spy.
+ settingsStore: { get: jest.fn(() => ({})), set: jest.fn() },
}));
/** Fire the manager's settings subscription with a before/after pair. */
@@ -75,6 +128,10 @@ interface MockSessionTestHandle {
setStatus(
status: "starting" | "idle" | "running" | "awaiting_permission" | "error" | "closed"
): void;
+ /** Seed the display messages so a manual save writes a file (and a path). */
+ setMessages(messages: { message: string }[]): void;
+ /** Toggle whether the session reports user-visible messages (detach gating). */
+ setHasUserVisibleMessages(value: boolean): void;
}
const sessionTestHandles = new Map();
@@ -89,11 +146,14 @@ function makeMockSession(overrides: {
internalId: string;
backendSessionId?: string;
backendId: string;
+ projectId?: string;
ready?: Promise;
}): AgentSession {
const sessionId = overrides.backendSessionId ?? `backend-${nextBackendSessionId++}`;
let status: "starting" | "idle" | "running" | "awaiting_permission" | "error" | "closed" = "idle";
let needsAttention = false;
+ let displayMessages: { message: string }[] = [];
+ let hasUserVisibleMessages = false;
const listeners = new Set<{
onStatusChanged?: (s: typeof status) => void;
onNeedsAttentionChanged?: (v: boolean) => void;
@@ -101,9 +161,11 @@ function makeMockSession(overrides: {
const session = {
internalId: overrides.internalId,
backendId: overrides.backendId,
+ projectId: overrides.projectId ?? GLOBAL_SCOPE,
ready: overrides.ready ?? Promise.resolve(),
getBackendSessionId: () => sessionId,
getStatus: () => status,
+ store: { getDisplayMessages: () => displayMessages },
cancel: mockSessionCancel,
dispose: mockSessionDispose,
setModel: jest.fn(),
@@ -115,7 +177,7 @@ function makeMockSession(overrides: {
listeners.add(l);
return () => listeners.delete(l);
},
- hasUserVisibleMessages: () => false,
+ hasUserVisibleMessages: () => hasUserVisibleMessages,
getState: () => null,
getRawSnapshot: () => ({ models: null, modes: null, configOptions: null }),
getNeedsAttention: () => needsAttention,
@@ -136,15 +198,23 @@ function makeMockSession(overrides: {
status = next;
for (const l of listeners) l.onStatusChanged?.(next);
},
+ setMessages: (messages) => {
+ displayMessages = messages;
+ },
+ setHasUserVisibleMessages: (value) => {
+ hasUserVisibleMessages = value;
+ },
});
return session;
}
-const sessionCreateSpy = jest
- .spyOn(AgentSession, "start")
- .mockImplementation((opts) =>
- makeMockSession({ internalId: opts.internalId, backendId: opts.backendId })
- );
+const sessionCreateSpy = jest.spyOn(AgentSession, "start").mockImplementation((opts) =>
+ makeMockSession({
+ internalId: opts.internalId,
+ backendId: opts.backendId,
+ projectId: opts.projectId,
+ })
+);
function buildApp(basePath = "/vault"): App {
const adapter = new (FileSystemAdapter as unknown as new (basePath: string) => unknown)(basePath);
@@ -946,6 +1016,165 @@ describe("AgentSessionManager attention tracking", () => {
});
});
+describe("AgentSessionManager.getRunningChatIds", () => {
+ // A manager whose saveSession returns a stable on-disk path, so we can drive
+ // a session into the "saved" (markdown path) recent-list identity.
+ function buildManagerWithPersistence(): AgentSessionManager {
+ const descriptor = buildDescriptor();
+ const persistence = {
+ saveSession: jest.fn(async () => ({ path: "chats/agent__saved.md" })),
+ };
+ return new AgentSessionManager(
+ buildApp(),
+ buildPlugin() as unknown as ConstructorParameters[1],
+ {
+ permissionPrompter: jest.fn(),
+ resolveDescriptor: (id) => (id === descriptor.id ? descriptor : undefined),
+ modelPreloader: {
+ getCachedBackendState: jest.fn(() => null),
+ preload: jest.fn(async () => undefined),
+ refresh: jest.fn(() => null),
+ subscribe: jest.fn(() => () => {}),
+ shutdown: jest.fn(),
+ setCached: jest.fn(),
+ clearCached: jest.fn(),
+ takeWarm: jest.fn(() => null),
+ getWarmProcs: jest.fn(() => []),
+ } as unknown as ConstructorParameters[2]["modelPreloader"],
+ persistenceManager: persistence as unknown as ConstructorParameters<
+ typeof AgentSessionManager
+ >[2]["persistenceManager"],
+ }
+ );
+ }
+
+ it("returns the same frozen empty set when nothing is running", async () => {
+ const mgr = buildManager();
+ const a = await mgr.createSession();
+ getSessionTestHandle(a).setStatus("idle");
+ const first = mgr.getRunningChatIds();
+ expect(first.size).toBe(0);
+ // Referential stability: an empty result must reuse the module constant.
+ expect(mgr.getRunningChatIds()).toBe(first);
+ });
+
+ it("keys a running saved session by its markdown path", async () => {
+ const mgr = buildManagerWithPersistence();
+ const a = await mgr.createSession();
+ getSessionTestHandle(a).setMessages([{ message: "hi" }]);
+ await mgr.saveActiveSession();
+ getSessionTestHandle(a).setStatus("running");
+ expect(mgr.getRunningChatIds().has("chats/agent__saved.md")).toBe(true);
+ });
+
+ it("keys a running native session by its native chat id", async () => {
+ const mgr = buildManager();
+ const a = await mgr.createSession();
+ getSessionTestHandle(a).setStatus("running");
+ const expected = buildNativeChatId(a.backendId, a.getBackendSessionId()!);
+ expect(mgr.getRunningChatIds().has(expected)).toBe(true);
+ });
+
+ it("excludes idle / starting / closed sessions", async () => {
+ const mgr = buildManager();
+ const a = await mgr.createSession();
+ const b = await mgr.createSession();
+ getSessionTestHandle(a).setStatus("running");
+ getSessionTestHandle(b).setStatus("starting");
+ const ids = mgr.getRunningChatIds();
+ expect(ids.has(buildNativeChatId(a.backendId, a.getBackendSessionId()!))).toBe(true);
+ expect(ids.has(buildNativeChatId(b.backendId, b.getBackendSessionId()!))).toBe(false);
+ });
+
+ it("notifies subscribers when a session's running membership flips", async () => {
+ const mgr = buildManager();
+ const a = await mgr.createSession();
+ const listener = jest.fn();
+ mgr.subscribe(listener);
+ getSessionTestHandle(a).setStatus("running");
+ expect(listener).toHaveBeenCalledTimes(1);
+ listener.mockClear();
+ getSessionTestHandle(a).setStatus("idle");
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps both ids when a running session's first save re-keys it (dual-id)", async () => {
+ const mgr = buildManagerWithPersistence();
+ const a = await mgr.createSession();
+ getSessionTestHandle(a).setMessages([{ message: "hi" }]);
+ getSessionTestHandle(a).setStatus("running");
+ // Before the save the running session is keyed by its native id.
+ const nativeId = buildNativeChatId(a.backendId, a.getBackendSessionId()!);
+ expect(mgr.getRunningChatIds().has(nativeId)).toBe(true);
+
+ const listener = jest.fn();
+ mgr.subscribe(listener);
+ await mgr.saveActiveSession();
+
+ // The save itself must not notify (autosave stays decoupled from row
+ // visibility). Instead the set now carries BOTH ids, so whichever id the
+ // mounted list rendered the row under, `.has()` still hits.
+ expect(listener).not.toHaveBeenCalled();
+ const ids = mgr.getRunningChatIds();
+ expect(ids.has("chats/agent__saved.md")).toBe(true);
+ expect(ids.has(nativeId)).toBe(true);
+ });
+});
+
+describe("AgentSessionManager.getAttentionChatIds", () => {
+ it("returns the same frozen empty set when nothing needs attention", async () => {
+ const mgr = buildManager();
+ await mgr.createSession();
+ const first = mgr.getAttentionChatIds();
+ expect(first.size).toBe(0);
+ expect(mgr.getAttentionChatIds()).toBe(first);
+ });
+
+ it("hands a backgrounded finished session over from running to attention", async () => {
+ const mgr = buildManager();
+ const a = await mgr.createSession();
+ const b = await mgr.createSession();
+ // b is active by default; switch to a so b runs in the background.
+ mgr.setActiveSession(a.internalId);
+ const bHandle = getSessionTestHandle(b);
+ const nativeId = buildNativeChatId(b.backendId, b.getBackendSessionId()!);
+
+ bHandle.setStatus("running");
+ expect(mgr.getRunningChatIds().has(nativeId)).toBe(true);
+ expect(mgr.getAttentionChatIds().has(nativeId)).toBe(false);
+
+ // Finishing in the background: the id must leave the running set and
+ // enter the attention set in the same status flip — the row's spinner
+ // hands off to the live done-dot without a history reload.
+ bHandle.setStatus("idle");
+ expect(mgr.getRunningChatIds().has(nativeId)).toBe(false);
+ expect(mgr.getAttentionChatIds().has(nativeId)).toBe(true);
+ });
+
+ it("does not include the active session (it never flags attention)", async () => {
+ const mgr = buildManager();
+ const a = await mgr.createSession();
+ const aHandle = getSessionTestHandle(a);
+ aHandle.setStatus("running");
+ aHandle.setStatus("idle");
+ expect(mgr.getAttentionChatIds().size).toBe(0);
+ });
+
+ it("drops the id once the user activates the flagged tab", async () => {
+ const mgr = buildManager();
+ const a = await mgr.createSession();
+ const b = await mgr.createSession();
+ mgr.setActiveSession(a.internalId);
+ const bHandle = getSessionTestHandle(b);
+ bHandle.setStatus("running");
+ bHandle.setStatus("idle");
+ const nativeId = buildNativeChatId(b.backendId, b.getBackendSessionId()!);
+ expect(mgr.getAttentionChatIds().has(nativeId)).toBe(true);
+ mgr.setActiveSession(b.internalId);
+ expect(mgr.getAttentionChatIds().has(nativeId)).toBe(false);
+ });
+});
+
describe("AgentSessionManager.replaceSessionInPlace", () => {
// Drains the fire-and-forget `closeSession` chain that
// replaceSessionInPlace kicks off, so assertions about pool removal
@@ -1562,6 +1791,7 @@ describe("AgentSessionManager chat history aggregation", () => {
backendId?: string;
sessionId?: string;
lastAccessedAt?: number;
+ projectId?: string;
}
function makeIndexStorage() {
@@ -1750,6 +1980,34 @@ describe("AgentSessionManager chat history aggregation", () => {
expect(items[0]?.id).toBe(buildNativeChatId("codex", "s9"));
});
+ it("scopes a project view's native entries by the index's recorded projectId", async () => {
+ // Autosave-off project chats have no markdown frontmatter — the index's
+ // recorded scope is the only thing that can place them in a project list.
+ const { manager, index } = buildHistoryHarness();
+ await index.recordSession({
+ backendId: "codex",
+ sessionId: "in-project",
+ title: "Project chat",
+ createdAtMs: 1_000,
+ lastAccessedAtMs: 2_000,
+ projectId: "proj-1",
+ });
+ await index.recordSession({
+ backendId: "codex",
+ sessionId: "global-chat",
+ title: "Global chat",
+ createdAtMs: 1_000,
+ lastAccessedAtMs: 2_000,
+ });
+
+ const projectItems = await manager.getChatHistoryItems("proj-1");
+ expect(projectItems).toHaveLength(1);
+ expect(projectItems[0]?.id).toBe(buildNativeChatId("codex", "in-project"));
+
+ // The global view stays the flat all-scopes list.
+ expect(await manager.getChatHistoryItems()).toHaveLength(2);
+ });
+
it("deleting a native entry tombstones it without touching persistence", async () => {
const { manager, index, persistence } = buildHistoryHarness();
await index.recordSession({
@@ -1865,6 +2123,137 @@ describe("AgentSessionManager chat history aggregation", () => {
expect(sessionExistsLocally).toHaveBeenCalledWith({ sessionId: "foreign", cwd: "/vault" });
});
+ it("probes a project chat's resumability with its project cwd, not the vault root", async () => {
+ // Regression: a backend that keys its transcript store by cwd (Claude) stores
+ // a project chat's transcript under the PROJECT folder. Probing it with the
+ // vault root would report a perfectly resumable local project chat as absent
+ // and hide it. The locality probe must use the chat's own scope cwd.
+ projectsState.updateCachedProjectRecords([
+ {
+ project: { id: "proj-1" },
+ filePath: "Projects/proj-1/project.md",
+ folderName: "proj-1",
+ } as unknown as ProjectFileRecord,
+ ]);
+ try {
+ const sessionExistsLocally = jest.fn(
+ async ({ cwd }: { cwd: string }) => cwd === "/vault/Projects/proj-1"
+ );
+ const { manager } = buildHistoryHarness({
+ files: {
+ "chats/agent__p.md": {
+ epoch: 2_000,
+ topic: "Project chat",
+ backendId: "opencode",
+ sessionId: "proj-sess",
+ projectId: "proj-1",
+ },
+ },
+ warmSessionExistsLocally: sessionExistsLocally,
+ });
+
+ const titles = (await manager.getChatHistoryItems("proj-1")).map((i) => i.title);
+ expect(titles).toContain("Project chat");
+ expect(sessionExistsLocally).toHaveBeenCalledWith({
+ sessionId: "proj-sess",
+ cwd: "/vault/Projects/proj-1",
+ });
+ } finally {
+ projectsState.updateCachedProjectRecords([]);
+ }
+ });
+
+ it("probes each chat with its own scope cwd in the global flat view", async () => {
+ // The global Recent Chats view is a flat all-scopes list, so it must probe a
+ // global chat against the vault root AND a project chat against its project
+ // folder in the same pass — otherwise the project row gets wrongly hidden.
+ projectsState.updateCachedProjectRecords([
+ {
+ project: { id: "proj-1" },
+ filePath: "Projects/proj-1/project.md",
+ folderName: "proj-1",
+ } as unknown as ProjectFileRecord,
+ ]);
+ try {
+ const sessionExistsLocally = jest.fn(async () => true);
+ const { manager } = buildHistoryHarness({
+ files: {
+ "chats/agent__g.md": {
+ epoch: 2_000,
+ topic: "Global chat",
+ backendId: "opencode",
+ sessionId: "g-sess",
+ },
+ "chats/agent__p.md": {
+ epoch: 1_000,
+ topic: "Project chat",
+ backendId: "opencode",
+ sessionId: "p-sess",
+ projectId: "proj-1",
+ },
+ },
+ warmSessionExistsLocally: sessionExistsLocally,
+ });
+
+ const titles = (await manager.getChatHistoryItems()).map((i) => i.title);
+ expect(titles).toEqual(expect.arrayContaining(["Global chat", "Project chat"]));
+ expect(sessionExistsLocally).toHaveBeenCalledWith({ sessionId: "g-sess", cwd: "/vault" });
+ expect(sessionExistsLocally).toHaveBeenCalledWith({
+ sessionId: "p-sess",
+ cwd: "/vault/Projects/proj-1",
+ });
+ } finally {
+ projectsState.updateCachedProjectRecords([]);
+ }
+ });
+
+ it("still hides a genuinely non-resumable project chat (probed against its project cwd)", async () => {
+ // The fix must not over-correct: a project chat synced from another machine
+ // is absent under its OWN project cwd too, so it stays hidden + tombstoned —
+ // identical behavior to a non-resumable global chat, just with the right cwd.
+ projectsState.updateCachedProjectRecords([
+ {
+ project: { id: "proj-1" },
+ filePath: "Projects/proj-1/project.md",
+ folderName: "proj-1",
+ } as unknown as ProjectFileRecord,
+ ]);
+ try {
+ const sessionExistsLocally = jest.fn(async () => false);
+ const { manager, index } = buildHistoryHarness({
+ files: {
+ "chats/agent__p.md": {
+ epoch: 2_000,
+ topic: "Foreign project chat",
+ backendId: "opencode",
+ sessionId: "foreign-proj",
+ projectId: "proj-1",
+ },
+ },
+ warmSessionExistsLocally: sessionExistsLocally,
+ });
+ await index.recordSession({
+ backendId: "opencode",
+ sessionId: "foreign-proj",
+ title: "Twin",
+ createdAtMs: 1_000,
+ lastAccessedAtMs: 2_000,
+ projectId: "proj-1",
+ });
+
+ const titles = (await manager.getChatHistoryItems("proj-1")).map((i) => i.title);
+ expect(titles).not.toContain("Foreign project chat");
+ expect(sessionExistsLocally).toHaveBeenCalledWith({
+ sessionId: "foreign-proj",
+ cwd: "/vault/Projects/proj-1",
+ });
+ // The native twin is tombstoned, same as the global non-resumable path.
+ expect(await index.isTombstoned("opencode", "foreign-proj")).toBe(true);
+ } finally {
+ projectsState.updateCachedProjectRecords([]);
+ }
+ });
+
it("tombstones the native twin of a dropped non-local markdown chat", async () => {
// A normal autosaved chat has both a note AND a flushIndexTouch index
// entry on its origin machine. When the note syncs to a second device but
@@ -1984,3 +2373,599 @@ describe("AgentSessionManager chat history aggregation", () => {
expect(items.filter((i) => i.id.startsWith("copilot-agent-session://"))).toHaveLength(0);
});
});
+
+describe("AgentSessionManager.enterProject MRU touch", () => {
+ const PROJECT_ID = "proj-mru";
+ let recordSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ mockTouchProjectLastUsed.mockClear();
+ (ProjectFileManager.getInstance as jest.Mock).mockClear();
+ // Make the scope resolvable: enterProject's orphan guard and resolveScopeCwd
+ // both read this, so a known id must return a record with a folder.
+ recordSpy = jest
+ .spyOn(projectsState, "getCachedProjectRecordById")
+ .mockImplementation((id: string) =>
+ id === PROJECT_ID
+ ? ({
+ filePath: "Projects/proj-mru/project.md",
+ project: { id: PROJECT_ID },
+ } as unknown as ReturnType)
+ : undefined
+ );
+ });
+
+ afterEach(() => recordSpy.mockRestore());
+
+ it("touches last-used after a successful enter that spawns a session", async () => {
+ const mgr = buildManager();
+ await mgr.enterProject(PROJECT_ID);
+ expect(ProjectFileManager.getInstance).toHaveBeenCalled();
+ expect(mockTouchProjectLastUsed).toHaveBeenCalledWith(PROJECT_ID);
+ });
+
+ it("touches last-used on the restored-session path (no re-spawn)", async () => {
+ const mgr = buildManager();
+ // First enter spawns the scope's session; leaving and re-entering reuses it.
+ await mgr.enterProject(PROJECT_ID);
+ await mgr.exitProject();
+ mockTouchProjectLastUsed.mockClear();
+ sessionCreateSpy.mockClear();
+
+ await mgr.enterProject(PROJECT_ID);
+
+ expect(sessionCreateSpy).not.toHaveBeenCalled();
+ expect(mockTouchProjectLastUsed).toHaveBeenCalledWith(PROJECT_ID);
+ });
+
+ it("does not touch when returning to the global scope", async () => {
+ const mgr = buildManager();
+ await mgr.enterProject(PROJECT_ID);
+ mockTouchProjectLastUsed.mockClear();
+
+ await mgr.exitProject();
+
+ expect(mockTouchProjectLastUsed).not.toHaveBeenCalled();
+ });
+
+ it("does not touch a re-click of the already-active project", async () => {
+ const mgr = buildManager();
+ await mgr.enterProject(PROJECT_ID);
+ mockTouchProjectLastUsed.mockClear();
+
+ // Same scope, live session — the early return must not count as a new use.
+ await mgr.enterProject(PROJECT_ID);
+
+ expect(mockTouchProjectLastUsed).not.toHaveBeenCalled();
+ });
+
+ it("does not touch when the spawn fails", async () => {
+ const mgr = buildManager();
+ // A rejected backend start makes getOrCreateActiveSession throw before the
+ // touch runs — a failed enter must not bump MRU.
+ mockBackendStart.mockRejectedValueOnce(new Error("spawn boom"));
+
+ await expect(mgr.enterProject(PROJECT_ID)).rejects.toThrow();
+
+ expect(mockTouchProjectLastUsed).not.toHaveBeenCalled();
+ });
+
+ it("touches only the current scope when another enter wins the spawn race", async () => {
+ const OTHER_ID = "proj-other";
+ recordSpy.mockImplementation((id: string) =>
+ id === PROJECT_ID || id === OTHER_ID
+ ? { filePath: `Projects/${id}/project.md`, project: { id } }
+ : undefined
+ );
+ const mgr = buildManager();
+
+ // Both enters set `activeProjectId` synchronously in call order, so the
+ // second call wins the active scope before either spawn's post-await touch
+ // runs (a later microtask). The first enter's touch must observe the moved
+ // scope and skip, crediting only the winner.
+ const enterStale = mgr.enterProject(PROJECT_ID);
+ const enterWinner = mgr.enterProject(OTHER_ID);
+ await Promise.all([enterStale, enterWinner]);
+
+ expect(mockTouchProjectLastUsed).toHaveBeenCalledWith(OTHER_ID);
+ expect(mockTouchProjectLastUsed).not.toHaveBeenCalledWith(PROJECT_ID);
+ });
+
+ it("rolls the active scope back when a cross-scope history load fails to resume", async () => {
+ const mgr = buildManager();
+ // The active session lives in the project scope.
+ await mgr.enterProject(PROJECT_ID);
+ const projectSession = mgr.getActiveSession();
+ expect(mgr.getActiveProjectId()).toBe(PROJECT_ID);
+
+ // Opening a global native chat on an unresolvable backend switches the
+ // active scope to global first, then rejects because the resume can't
+ // start. The failure must restore the project scope — otherwise the active
+ // scope and the (unchanged) active session would disagree.
+ await expect(mgr.loadNativeSessionFromHistory("codex", "missing")).rejects.toThrow();
+
+ expect(mgr.getActiveProjectId()).toBe(PROJECT_ID);
+ expect(mgr.getActiveSession()).toBe(projectSession);
+ });
+
+ it("rolls the entered scope back when its auto-spawn fails", async () => {
+ const OTHER_ID = "proj-other-fail";
+ recordSpy.mockImplementation((id: string) =>
+ id === PROJECT_ID || id === OTHER_ID
+ ? { filePath: `Projects/${id}/project.md`, project: { id } }
+ : undefined
+ );
+ const mgr = buildManager();
+ // Land in a project scope with a live session — the exact state a failed
+ // entry into a DIFFERENT project must restore.
+ await mgr.enterProject(PROJECT_ID);
+ const projectSession = mgr.getActiveSession();
+ expect(mgr.getActiveProjectId()).toBe(PROJECT_ID);
+
+ // Entering OTHER_ID switches the active scope and nulls the active session
+ // before awaiting the fresh spawn; the spawn then rejects. (Inject at
+ // AgentSession.start, which createSession always calls even on a warm proc —
+ // the prior enter left the backend running, so a cold-start reject wouldn't
+ // fire here.) The rollback must restore the prior scope + session rather than
+ // strand the manager in a chatless OTHER_ID scope (callers only show a Notice).
+ sessionCreateSpy.mockImplementationOnce(() => {
+ throw new Error("spawn boom");
+ });
+ await expect(mgr.enterProject(OTHER_ID)).rejects.toThrow();
+
+ expect(mgr.getActiveProjectId()).toBe(PROJECT_ID);
+ expect(mgr.getActiveSession()).toBe(projectSession);
+ });
+});
+
+describe("AgentSessionManager fresh-visit tab detach", () => {
+ const PROJECT_ID = "proj-detach";
+ let recordSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ sessionCreateSpy.mockClear();
+ recordSpy = jest
+ .spyOn(projectsState, "getCachedProjectRecordById")
+ .mockImplementation((id: string) =>
+ id === PROJECT_ID
+ ? ({
+ filePath: "Projects/proj-detach/project.md",
+ project: { id: PROJECT_ID },
+ } as unknown as ReturnType)
+ : undefined
+ );
+ });
+
+ afterEach(() => recordSpy.mockRestore());
+
+ /** Enter the project, mark its active session conversational, then leave. */
+ async function enterWithConversation(mgr: AgentSessionManager): Promise {
+ await mgr.enterProject(PROJECT_ID);
+ const session = mgr.getActiveSession();
+ if (!session) throw new Error("expected an active session after enter");
+ getSessionTestHandle(session).setHasUserVisibleMessages(true);
+ await mgr.exitProject();
+ return session;
+ }
+
+ it("detaches a conversational session on re-entry and spawns a fresh one", async () => {
+ const mgr = buildManager();
+ const old = await enterWithConversation(mgr);
+
+ sessionCreateSpy.mockClear();
+ await mgr.enterProject(PROJECT_ID);
+
+ // A fresh session was spawned and is the only tab shown for the scope.
+ expect(sessionCreateSpy).toHaveBeenCalledTimes(1);
+ const visible = mgr.getSessionsForScope(PROJECT_ID);
+ expect(visible).toHaveLength(1);
+ expect(visible[0].internalId).not.toBe(old.internalId);
+ // The old session is hidden from the strip but still alive in the pool.
+ expect(mgr.getSessionsForScope(PROJECT_ID)).not.toContain(old);
+ expect(mgr.getSessions()).toContain(old);
+ });
+
+ it("does not reuse an error-state landing; spawns a fresh session instead", async () => {
+ const mgr = buildManager();
+ await mgr.enterProject(PROJECT_ID);
+ const landing = mgr.getActiveSession();
+ if (!landing) throw new Error("expected a landing session");
+ // Its backend never opened — not a clean slate to adopt.
+ getSessionTestHandle(landing).setStatus("error");
+ await mgr.exitProject();
+
+ sessionCreateSpy.mockClear();
+ await mgr.enterProject(PROJECT_ID);
+
+ expect(sessionCreateSpy).toHaveBeenCalledTimes(1);
+ expect(mgr.getActiveSession()).not.toBe(landing);
+ });
+
+ it("reuses an empty landing session instead of stacking a blank tab", async () => {
+ const mgr = buildManager();
+ await mgr.enterProject(PROJECT_ID);
+ const landing = mgr.getActiveSession();
+ await mgr.exitProject();
+
+ sessionCreateSpy.mockClear();
+ await mgr.enterProject(PROJECT_ID);
+
+ expect(sessionCreateSpy).not.toHaveBeenCalled();
+ expect(mgr.getActiveSession()).toBe(landing);
+ expect(mgr.getSessionsForScope(PROJECT_ID)).toHaveLength(1);
+ });
+
+ it("keeps a detached running session in getRunningChatIds (history spinner)", async () => {
+ const mgr = buildManager();
+ const old = await enterWithConversation(mgr);
+ getSessionTestHandle(old).setStatus("running");
+
+ await mgr.enterProject(PROJECT_ID);
+
+ expect(mgr.getSessionsForScope(PROJECT_ID)).not.toContain(old);
+ expect(mgr.getRunningChatIds().size).toBeGreaterThan(0);
+ });
+
+ it("keeps a detached needs-attention session in getAttentionChatIds (history dot)", async () => {
+ const mgr = buildManager();
+ const old = await enterWithConversation(mgr);
+ old.markNeedsAttention();
+
+ await mgr.enterProject(PROJECT_ID);
+
+ expect(mgr.getSessionsForScope(PROJECT_ID)).not.toContain(old);
+ expect(mgr.getAttentionChatIds().size).toBeGreaterThan(0);
+ });
+
+ it("re-attaches a detached session when it is surfaced via setActiveSession", async () => {
+ const mgr = buildManager();
+ const old = await enterWithConversation(mgr);
+ await mgr.enterProject(PROJECT_ID);
+ expect(mgr.getSessionsForScope(PROJECT_ID)).not.toContain(old);
+
+ mgr.setActiveSession(old.internalId);
+
+ expect(mgr.getSessionsForScope(PROJECT_ID)).toContain(old);
+ expect(mgr.getActiveSession()).toBe(old);
+ });
+
+ it("closeSession picks a visible neighbor, never a detached session", async () => {
+ const mgr = buildManager();
+ const old = await enterWithConversation(mgr);
+ await mgr.enterProject(PROJECT_ID);
+ const fresh = mgr.getActiveSession();
+ if (!fresh) throw new Error("expected a fresh active session");
+
+ await mgr.closeSession(fresh.internalId);
+
+ // The only other in-scope session is detached, so there is no visible
+ // neighbor to fall back to — the active pointer must not resurrect `old`.
+ expect(mgr.getActiveSession()).not.toBe(old);
+ });
+});
+
+describe("AgentSessionManager context-source dirty tracking", () => {
+ const PID = "proj-dirty";
+
+ function makeRecord(
+ contextSource: ProjectConfig["contextSource"],
+ usageTimestamps = 0,
+ systemPrompt = ""
+ ): ProjectFileRecord {
+ return {
+ project: {
+ id: PID,
+ name: PID,
+ systemPrompt,
+ projectModelKey: "",
+ modelConfigs: {},
+ contextSource,
+ created: 0,
+ UsageTimestamps: usageTimestamps,
+ },
+ filePath: `Projects/${PID}/project.md`,
+ folderName: PID,
+ };
+ }
+
+ /** Publish a record set to the real store (drives subscribeToProjectRecords). */
+ function publish(record: ProjectFileRecord): void {
+ projectsState.updateCachedProjectRecords([record]);
+ }
+
+ // Dirty-clearing is chained off the (resolved) contextReady promise, so let
+ // the microtask queue drain before asserting the dirty flag's effect.
+ const flushAsync = () => new Promise((resolve) => window.setTimeout(resolve, 0));
+
+ // Track managers so each is shut down (unsubscribed) after its test —
+ // otherwise a prior test's still-subscribed manager would react to the next
+ // test's `publish()` and warm again, contaminating the call counts.
+ const builtManagers: AgentSessionManager[] = [];
+ function buildTrackedManager(): AgentSessionManager {
+ const mgr = buildManager();
+ builtManagers.push(mgr);
+ return mgr;
+ }
+
+ beforeEach(() => {
+ mockEnsureMaterialized.mockClear();
+ sessionCreateSpy.mockClear();
+ projectsState.updateCachedProjectRecords([]);
+ });
+
+ afterEach(async () => {
+ await Promise.all(builtManagers.splice(0).map((mgr) => mgr.shutdown().catch(() => {})));
+ projectsState.updateCachedProjectRecords([]);
+ });
+
+ it("marks an inactive project dirty so re-entry detaches the stale empty landing and respawns", async () => {
+ publish(makeRecord({ webUrls: "https://a.com" }));
+ const mgr = buildTrackedManager();
+
+ await mgr.enterProject(PID);
+ const landing = mgr.getActiveSession();
+ if (!landing) throw new Error("expected a landing session");
+ await mgr.exitProject();
+
+ // Source edit for the (now inactive) project: marks it dirty.
+ publish(makeRecord({ webUrls: "https://a.com\nhttps://b.com" }));
+
+ sessionCreateSpy.mockClear();
+ await mgr.enterProject(PID);
+
+ // The stale empty landing is gone from the strip; a fresh session took over.
+ expect(sessionCreateSpy).toHaveBeenCalledTimes(1);
+ const visible = mgr.getSessionsForScope(PID);
+ expect(visible).toHaveLength(1);
+ expect(visible[0]).not.toBe(landing);
+ expect(mgr.getSessionsForScope(PID)).not.toContain(landing);
+ });
+
+ it("clears dirty once a fresh session captured the new sources (later re-entry reuses)", async () => {
+ publish(makeRecord({ webUrls: "https://a.com" }));
+ const mgr = buildTrackedManager();
+ await mgr.enterProject(PID);
+ await mgr.exitProject();
+
+ publish(makeRecord({ webUrls: "https://a.com\nhttps://b.com" }));
+ await mgr.enterProject(PID); // dirty → fresh spawn
+ await flushAsync(); // let the post-materialization dirty-clear run
+ const fresh = mgr.getActiveSession();
+ await mgr.exitProject();
+
+ sessionCreateSpy.mockClear();
+ await mgr.enterProject(PID); // no longer dirty → reuse the empty landing
+
+ expect(sessionCreateSpy).not.toHaveBeenCalled();
+ expect(mgr.getActiveSession()).toBe(fresh);
+ });
+
+ it("keeps a project dirty when the fresh session fails to start (ready rejects)", async () => {
+ publish(makeRecord({ webUrls: "https://a.com" }));
+ const mgr = buildTrackedManager();
+ await mgr.enterProject(PID);
+ await flushAsync();
+ await mgr.exitProject();
+
+ publish(makeRecord({ webUrls: "https://a.com\nhttps://b.com" })); // dirty = v2
+
+ // The dirty re-entry's fresh session fails to start: ready rejects, so the
+ // ready-success dirty-clear never runs.
+ sessionCreateSpy.mockImplementationOnce((opts) => {
+ const rejected = Promise.reject(new Error("startup boom"));
+ rejected.catch(() => {}); // swallow the unhandled-rejection warning
+ return makeMockSession({
+ internalId: opts.internalId,
+ backendId: opts.backendId,
+ projectId: opts.projectId,
+ ready: rejected,
+ });
+ });
+ await mgr.enterProject(PID);
+ await flushAsync();
+ await mgr.exitProject();
+
+ // dirty must SURVIVE (no session captured the new sources) → re-entry respawns.
+ sessionCreateSpy.mockClear();
+ await mgr.enterProject(PID);
+ expect(sessionCreateSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps a project dirty when the create captured an older signature (single-flight race)", async () => {
+ publish(makeRecord({ webUrls: "https://a.com" }));
+ const mgr = buildTrackedManager();
+ await mgr.enterProject(PID);
+ await mgr.exitProject();
+
+ // Source advances to v2 → dirty = signature(v2).
+ publish(makeRecord({ webUrls: "https://a.com\nhttps://b.com" }));
+
+ // Simulate the next create JOINING an in-flight run that materialized the
+ // OLDER v1 record: the result carries v1's signature, not the live v2.
+ const v1Signature = getProjectContextSignature(makeRecord({ webUrls: "https://a.com" }));
+ mockEnsureMaterialized.mockImplementationOnce(async () => ({
+ additionalDirectories: [],
+ contextSignature: v1Signature,
+ }));
+
+ await mgr.enterProject(PID); // dirty → fresh spawn capturing stale v1
+ await flushAsync();
+ await mgr.exitProject();
+
+ // v1 ≠ dirty(v2), so the flag must SURVIVE — the stale landing is not reused.
+ sessionCreateSpy.mockClear();
+ await mgr.enterProject(PID);
+ expect(sessionCreateSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it("ignores a usage-timestamp-only touch (no dirty, empty landing still reused)", async () => {
+ publish(makeRecord({ webUrls: "https://a.com" }));
+ const mgr = buildTrackedManager();
+ await mgr.enterProject(PID);
+ const landing = mgr.getActiveSession();
+ await mgr.exitProject();
+
+ // Same context source, only the MRU timestamp moved — must NOT dirty.
+ publish(makeRecord({ webUrls: "https://a.com" }, 12345));
+
+ sessionCreateSpy.mockClear();
+ await mgr.enterProject(PID);
+
+ expect(sessionCreateSpy).not.toHaveBeenCalled();
+ expect(mgr.getActiveSession()).toBe(landing);
+ });
+
+ it("does not reuse an empty landing after a System-Prompt-only edit", async () => {
+ publish(makeRecord({ webUrls: "https://a.com" }, 0, "old instructions"));
+ const mgr = buildTrackedManager();
+ await mgr.enterProject(PID);
+ const landing = mgr.getActiveSession();
+ if (!landing) throw new Error("expected a landing session");
+ await mgr.exitProject();
+
+ // The materialization signature is unchanged (sources identical), so the
+ // project is NOT dirty — but the landing-capture signature changed, so the
+ // stale landing (which baked in the old instructions) must not be reused.
+ publish(makeRecord({ webUrls: "https://a.com" }, 0, "new instructions"));
+
+ sessionCreateSpy.mockClear();
+ await mgr.enterProject(PID);
+
+ expect(sessionCreateSpy).toHaveBeenCalledTimes(1);
+ expect(mgr.getActiveSession()).not.toBe(landing);
+ });
+
+ it("rematerializeContext forces a retry of known-bad sources", async () => {
+ publish(makeRecord({ webUrls: "https://a.com" }));
+ const mgr = buildTrackedManager();
+ await mgr.enterProject(PID);
+ await flushAsync();
+ mockEnsureMaterialized.mockClear();
+
+ const started = mgr.rematerializeContext(PID);
+ await flushAsync();
+
+ expect(started).toBe(true);
+ expect(mockEnsureMaterialized).toHaveBeenCalledTimes(1);
+ // The 5th arg (forceRetryFailed) is forwarded as true so the materializer
+ // re-fetches sources whose failure markers the automatic path would honor.
+ expect(mockEnsureMaterialized.mock.calls[0][4]).toBe(true);
+ });
+
+ it("rematerializeContext early-exits while a run already owns the load atom", async () => {
+ publish(makeRecord({ webUrls: "https://a.com" }));
+ const mgr = buildTrackedManager();
+ await mgr.enterProject(PID);
+ await flushAsync();
+ mockEnsureMaterialized.mockClear();
+
+ // A full run is blocking the atom: the forced retry would otherwise join it
+ // and have its force swallowed, so it must early-exit instead.
+ const getMock = jest.requireMock("@/settings/model").settingsStore.get as jest.Mock;
+ getMock.mockReturnValueOnce({ [PID]: { phase: "prefetch", blocking: true } });
+
+ const started = mgr.rematerializeContext(PID);
+
+ expect(started).toBe(false);
+ expect(mockEnsureMaterialized).not.toHaveBeenCalled();
+ });
+
+ it("warms the active project's cache on a source edit without gating the composer", async () => {
+ publish(makeRecord({ webUrls: "https://a.com" }));
+ const mgr = buildTrackedManager();
+ await mgr.enterProject(PID); // active = PID
+ await flushAsync(); // let create-time materialization + atom writes settle
+
+ // Clear create-time materialization + atom writes, isolate the warm.
+ mockEnsureMaterialized.mockClear();
+ const settingsStoreSet = jest.requireMock("@/settings/model").settingsStore.set as jest.Mock;
+ settingsStoreSet.mockClear();
+
+ publish(makeRecord({ webUrls: "https://a.com\nhttps://b.com" }));
+
+ // Background warm ran (disk refresh) but never published a blocking load
+ // state — the composer of the session that won't consume it stays ungated.
+ expect(mockEnsureMaterialized).toHaveBeenCalledTimes(1);
+ expect(settingsStoreSet).not.toHaveBeenCalled();
+ });
+
+ it("stops reacting to record changes after shutdown", async () => {
+ publish(makeRecord({ webUrls: "https://a.com" }));
+ const mgr = buildTrackedManager();
+ await mgr.enterProject(PID);
+ await mgr.shutdown();
+
+ mockEnsureMaterialized.mockClear();
+ // A post-shutdown edit must not warm or throw.
+ expect(() => publish(makeRecord({ webUrls: "https://a.com\nhttps://b.com" }))).not.toThrow();
+ expect(mockEnsureMaterialized).not.toHaveBeenCalled();
+ });
+
+ it("publishes processingSources + incremental failedSources during a run, clears at done", async () => {
+ publish(makeRecord({ webUrls: "https://a.com" }));
+ const mgr = buildTrackedManager();
+ const setMock = jest.requireMock("@/settings/model").settingsStore.set as jest.Mock;
+ // Reconstruct the latest published load-state by replaying the most recent
+ // `settingsStore.set(agentProjectContextLoadAtom, updater)` call (the store is
+ // mocked, so there is no live atom to read back).
+ const latest = (): AgentProjectContextLoadState | undefined => {
+ for (let i = setMock.mock.calls.length - 1; i >= 0; i--) {
+ const [atom, updater] = setMock.mock.calls[i];
+ if (atom !== agentProjectContextLoadAtom || typeof updater !== "function") continue;
+ const next = updater({}) as Record;
+ if (next[PID]) return next[PID];
+ }
+ return undefined;
+ };
+
+ let drive!: (p: ContextMaterializeProgress) => void;
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ mockEnsureMaterialized.mockImplementationOnce(
+ async (
+ _app: unknown,
+ _pid: string,
+ _cwd: string,
+ onProgress: (p: ContextMaterializeProgress) => void
+ ) => {
+ drive = onProgress;
+ onProgress({ phase: "prefetch", done: 0, total: 1 });
+ onProgress({ phase: "itemStart", item: { kind: "web", source: "https://a.com" } });
+ await gate;
+ return { additionalDirectories: [] };
+ }
+ );
+
+ const entering = mgr.enterProject(PID);
+ await flushAsync();
+
+ // Mid-flight: the source being fetched is published in `processingSources`.
+ expect(latest()).toMatchObject({
+ phase: "prefetch",
+ blocking: true,
+ processingSources: [{ kind: "web", source: "https://a.com" }],
+ });
+
+ // Settle as a failure: it leaves `processingSources` and lands in
+ // `failedSources` immediately — not deferred to the end of the run.
+ drive({
+ phase: "itemFailed",
+ item: { kind: "web", source: "https://a.com" },
+ failure: { kind: "web", source: "https://a.com", error: "boom", usedStaleSnapshot: false },
+ });
+ const afterFail = latest()!;
+ expect(afterFail.processingSources).toBeUndefined();
+ expect(afterFail.failedSources).toEqual([
+ { path: "https://a.com", type: "web", error: "boom", usedStaleSnapshot: false },
+ ]);
+
+ release();
+ await entering;
+ await flushAsync(); // let the materialize `.then()` publish the terminal "done"
+ // Done: nothing is processing.
+ expect(latest()).toMatchObject({ phase: "done", blocking: false });
+ expect(latest()!.processingSources).toBeUndefined();
+ });
+});
diff --git a/src/agentMode/session/AgentSessionManager.ts b/src/agentMode/session/AgentSessionManager.ts
index ab068958..b00754c3 100644
--- a/src/agentMode/session/AgentSessionManager.ts
+++ b/src/agentMode/session/AgentSessionManager.ts
@@ -1,22 +1,42 @@
import { logError, logInfo, logWarn } from "@/logger";
import type CopilotPlugin from "@/main";
import { AgentChatUIState } from "@/agentMode/session/AgentChatUIState";
+import {
+ agentProjectContextLoadAtom,
+ type AgentInFlightSource,
+ type AgentProjectContextLoadState,
+ type ContextLoadStepCount,
+ type FailedItem,
+} from "@/aiParams";
import {
getSettings,
setSettings,
+ settingsStore,
subscribeToSettingsChange,
type CopilotSettings,
} from "@/settings/model";
+import {
+ ensureProjectContextMaterialized,
+ EMPTY_CONTEXT_MATERIALIZATION_RESULT,
+ materializeProjectContextSource,
+ type ContextMaterializationResult,
+ type ContextMaterializeProgress,
+} from "@/context/projectContextMaterializer";
+import type {
+ MaterializedSourceType,
+ MaterializeSourceIdentity,
+ SourceFailure,
+} from "@/context/contextCacheStore";
import { err2String } from "@/utils";
import type { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover";
-import { fileToHistoryItem } from "@/utils/chatHistoryUtils";
+import { fileToHistoryItem, readChatPathProjectId } from "@/utils/chatHistoryUtils";
import { readFrontmatterViaAdapter } from "@/utils/vaultAdapterUtils";
import { App, FileSystemAdapter, Notice, Platform, TFile } from "obsidian";
import { v4 as uuidv4 } from "uuid";
import { AgentSession, ATTENTION_TRIGGER_STATUSES, DEFAULT_TITLE_PREFIX } from "./AgentSession";
import type { AgentChatPersistenceManager } from "./AgentChatPersistenceManager";
import type { AgentModelPreloader, WarmBackend } from "./AgentModelPreloader";
-import { parseNativeChatId } from "@/utils/nativeChatId";
+import { buildNativeChatId, parseNativeChatId } from "@/utils/nativeChatId";
import type { AgentSessionIndex } from "./AgentSessionIndex";
import {
deriveChatTitleFromMessages,
@@ -33,6 +53,26 @@ import {
} from "./fanout/FanoutOrchestrator";
import type { FanoutTurn } from "./fanout/fanoutTypes";
import { backendStateSignature } from "./translateBackendState";
+import { GLOBAL_SCOPE, type ProjectScopeId } from "./scope";
+import {
+ OrphanedProjectError,
+ pickScopeNeighbor,
+ resolveProjectIdForCwd,
+ resolveScopeCwd,
+} from "./sessionScope";
+import {
+ getCachedProjectRecordById,
+ getCachedProjectRecords,
+ subscribeToProjectRecords,
+} from "@/projects/state";
+import type { ProjectFileRecord } from "@/projects/type";
+import {
+ getProjectContextSignature,
+ getProjectLandingCaptureSignature,
+} from "@/projects/projectContextSignature";
+import { ProjectFileManager } from "@/projects/ProjectFileManager";
+import { ensureAgentsMirror } from "@/projects/ensureAgentsMirror";
+import { getComposedProjectInstructions } from "@/projects/projectSystemPrompt";
import type {
AgentQuestionAnswers,
AskUserQuestionPrompt,
@@ -47,6 +87,7 @@ import type {
ModelSelection,
PermissionDecision,
PermissionPrompt,
+ ProjectProfile,
SessionId,
} from "./types";
@@ -86,6 +127,71 @@ function isSameCwd(a: string, b: string): boolean {
return norm(a) === norm(b);
}
+// Referential-stability constants for "empty" returns — never hand back a fresh
+// `[]` (would churn React consumers of these getters).
+const EMPTY_SESSIONS = Object.freeze([]) as unknown as AgentSession[];
+const EMPTY_HISTORY_ITEMS = Object.freeze([]) as unknown as ChatHistoryItem[];
+// DESIGN NOTE — intentionally NOT Object.freeze'd, unlike the array constants above.
+// (a) Goal is referential stability: an empty `getRunningChatIds()` /
+// `getAttentionChatIds()` always hands back this same instance, so React
+// consumers compare by identity (tests assert `toBe`).
+// (b) `Object.freeze` is a no-op for a Set's contents — `Object.freeze(new Set()).add(x)`
+// still succeeds (freeze guards own properties, not the internal `[[SetData]]` slot),
+// so wrapping it would buy nothing the array constants' freeze buys.
+// (c) Runtime immutability rests on the `ReadonlySet` type (no `.add`/`.delete` in
+// the surface) plus the convention that no caller mutates the result — consumers only
+// `.has()`, read `.size`, and iterate.
+const EMPTY_RECENT_CHAT_IDS: ReadonlySet = new Set();
+
+// Backends that discover project instructions from a physical `AGENTS.md` in the session cwd.
+// (claude instead has `project.md` parsed and injected in-process via setProjectProfileProvider,
+// so it needs no file.) Only these require the generated mirror to exist before cwd is read.
+const CWD_INSTRUCTION_BACKENDS: ReadonlySet = new Set(["codex", "opencode"]);
+
+/**
+ * Map a materializer {@link SourceFailure} to the atom's {@link FailedItem}. The
+ * cache layer's `"file"` kind becomes the UI's `"nonMd"` (markdown is never
+ * materialized, so an agent failure is only ever web/youtube/nonMd).
+ */
+function toFailedItem(failure: SourceFailure): FailedItem {
+ return {
+ path: failure.source,
+ type: failure.kind === "file" ? "nonMd" : failure.kind,
+ error: failure.error,
+ usedStaleSnapshot: failure.usedStaleSnapshot,
+ };
+}
+
+/** Whether a {@link FailedItem} refers to the same source as a lifecycle event
+ * (the cache layer's `"file"` kind is the atom's `"nonMd"`). */
+function failedItemIsSource(failed: FailedItem, item: MaterializeSourceIdentity): boolean {
+ if (failed.path !== item.source) return false;
+ return item.kind === "file" ? failed.type === "nonMd" : failed.type === item.kind;
+}
+
+/** Add a source to the live "processing" set (no-op if already present). Returns a
+ * NEW array on change so the atom publish is referentially distinct. */
+function addProcessingSource(
+ list: AgentInFlightSource[],
+ item: MaterializeSourceIdentity
+): AgentInFlightSource[] {
+ if (list.some((s) => s.kind === item.kind && s.source === item.source)) return list;
+ return [...list, { kind: item.kind, source: item.source }];
+}
+
+/** Remove a source from the live "processing" set. */
+function removeProcessingSource(
+ list: AgentInFlightSource[],
+ item: MaterializeSourceIdentity
+): AgentInFlightSource[] {
+ return list.filter((s) => !(s.kind === item.kind && s.source === item.source));
+}
+
+/** Append a freshly-settled failure, dropping any prior entry for the same source. */
+function upsertFailedItem(list: FailedItem[], failure: FailedItem): FailedItem[] {
+ return [...list.filter((f) => !(f.path === failure.path && f.type === failure.type)), failure];
+}
+
export type PermissionPrompter = (req: PermissionPrompt) => Promise;
/**
@@ -144,9 +250,48 @@ export class AgentSessionManager {
private sessions = new Map();
private chatUIStates = new Map();
private activeSessionId: string | null = null;
- // Dedupe only the auto-spawn path. Direct `createSession()` calls (e.g. `+`
- // clicks) are independent — concurrent ones each spawn their own session.
- private firstSessionPromise: Promise | null = null;
+ // The scope the active session belongs to. Invariant:
+ // `activeSession.projectId === activeProjectId` (active always belongs to the
+ // current scope). `GLOBAL_SCOPE` is the implicit global workspace.
+ private activeProjectId: ProjectScopeId = GLOBAL_SCOPE;
+ // Per-scope most-recently-used session id, so re-entering a scope restores
+ // the tab the user last looked at there.
+ private readonly lastActiveByScope = new Map();
+ // Live sessions hidden from the current tab strip. Re-entering a project
+ // detaches its prior conversational sessions here so the strip shows only the
+ // fresh visit's workset — but they stay in `this.sessions` (and on disk), so
+ // chat history still lists them and their running/attention indicators stay
+ // live. Re-attached by `setActiveSession` (history open / tab click) and
+ // pruned whenever a session leaves the pool. Keyed by the globally-unique
+ // `internalId`, so no per-scope bucketing is needed.
+ private readonly detachedFromTabIds = new Set();
+ // Projects whose context sources changed since their last materialization,
+ // keyed by id → the context signature AT THE TIME the change was observed.
+ // A dirty project must NOT reuse a pre-existing empty landing on re-entry (its
+ // captured `` is stale) and must NOT keep a stale landing in
+ // the strip. Cleared once a freshly-created session captures the SAME
+ // signature — a newer edit's signature won't match, so its dirtiness survives
+ // (no lost update). Mirrors chat mode's `markdownNeedsReload`.
+ private readonly contextDirtySignatures = new Map();
+ // A project landing session captures MORE than materialized context at
+ // creation: codex/opencode read the AGENTS.md mirror from cwd, and Claude
+ // captures its project-instructions append. The materialization dirty flag
+ // above deliberately ignores `systemPrompt`, so reuse decisions need their own
+ // fingerprint of what the session actually baked in (the landing-capture
+ // signature). Keyed by internal session id; every project session gets an
+ // entry, but only landing-shaped ones (idle/empty) are ever consulted for
+ // reuse. A missing entry means "not reusable as a project landing".
+ private readonly landingCaptureSignatures = new Map();
+ // Snapshot of project records from the previous change notification, diffed
+ // against the next to detect context-source edits. Seeded in the constructor.
+ private previousProjectRecords: ProjectFileRecord[] = [];
+ private projectRecordsUnsubscriber?: () => void;
+ // Dedupe only the auto-spawn path, PER scope. Direct `createSession()` calls
+ // (e.g. `+` clicks) are independent — concurrent ones each spawn their own
+ // session. Keyed by scope so entering two scopes can't share one in-flight
+ // spawn; the key is cleared in `finally` so the next enter doesn't reuse a
+ // settled promise.
+ private readonly firstSessionPromiseByScope = new Map>();
private pendingCreates = 0;
private listeners = new Set<() => void>();
private disposed = false;
@@ -172,7 +317,8 @@ export class AgentSessionManager {
// - `unsub`: tear-down for the auto-save `session.subscribe()`
// - `signature`: last serialized snapshot, for no-op skipping
// - `modelCacheUnsub`: tear-down for the model-cache mirror subscription
- // - `attentionUnsub`: tear-down for the needs-attention status watcher
+ // - `attentionUnsub`: tear-down for the per-session status watcher that both
+ // flags needs-attention AND notifies on running-membership flips (spinner)
private readonly sessionState = new Map<
string,
{
@@ -221,6 +367,7 @@ export class AgentSessionManager {
this.settingsUnsub = subscribeToSettingsChange((prev, next) =>
this.onDefaultSelectionsChanged(prev, next)
);
+ this.setupProjectRecordChangeMonitor();
}
/**
@@ -317,6 +464,17 @@ export class AgentSessionManager {
getDefaultSelection: (backendId) =>
this.getDefaultSelection(backendId) ?? this.nativeDefaultSelection(backendId),
getDisplayName: (backendId) => this.resolveDescriptor(backendId).displayName,
+ // DESIGN NOTE: fan-out sub-sessions intentionally run at the vault root,
+ // not the originating session's project folder, and aren't handed the
+ // project's `projectId` / `additionalDirectories`. This is the seam where
+ // multi-agent QA (which predates project workspaces) meets project scope.
+ // It's acceptable because the project's knowledge already reaches every
+ // answerer: `runTurn` injects the first-turn `` block into
+ // the shared fan-out prompt. What a sub-session lacks is project-scoped
+ // tool *reach* (cwd + external roots) — tolerable for ephemeral read-only
+ // QA, whose answers are advisory and whose authoritative, fully-scoped reply
+ // comes from the main session. Threading project scope into the sub-sessions
+ // is a deliberate follow-up, not part of the project-workspace landing.
getCwd: () => {
const adapter = this.app.vault.adapter;
return adapter instanceof FileSystemAdapter ? adapter.getBasePath() : null;
@@ -333,6 +491,91 @@ export class AgentSessionManager {
};
}
+ /**
+ * Watch project config edits so a changed context source (URLs / inclusions /
+ * etc.) invalidates the project's materialized context — mirroring chat mode's
+ * {@link ProjectManager.setupProjectListChangeMonitor}. Seeds the prior-records
+ * snapshot up front so the first notification diffs against the real baseline.
+ */
+ private setupProjectRecordChangeMonitor(): void {
+ this.previousProjectRecords = getCachedProjectRecords();
+ this.projectRecordsUnsubscriber?.();
+ this.projectRecordsUnsubscriber = subscribeToProjectRecords((nextRecords) => {
+ this.handleProjectRecordsChanged(nextRecords);
+ });
+ }
+
+ /**
+ * Diff the new project records against the previous snapshot and mark any
+ * project whose context signature changed as dirty (its materialized context
+ * is stale). For the ACTIVE project we also warm its off-vault conversion
+ * cache in the background so the next chat there starts with sources already
+ * fetched — the
+ * warm never publishes to {@link agentProjectContextLoadAtom}, so it can never
+ * gate the composer of a session that won't even consume the new context.
+ */
+ private handleProjectRecordsChanged(nextRecords: ProjectFileRecord[]): void {
+ if (this.disposed) return;
+ const prevRecords = this.previousProjectRecords;
+ this.previousProjectRecords = nextRecords;
+
+ // Drop dirty flags for projects that no longer exist (deleted/renamed-away).
+ const nextIds = new Set(nextRecords.map((r) => r.project.id));
+ for (const id of this.contextDirtySignatures.keys()) {
+ if (!nextIds.has(id)) this.contextDirtySignatures.delete(id);
+ }
+
+ for (const nextRecord of nextRecords) {
+ const prevRecord = prevRecords.find((r) => r.project.id === nextRecord.project.id);
+ if (!prevRecord) continue; // brand-new project: nothing materialized yet
+ const nextSignature = getProjectContextSignature(nextRecord);
+ if (getProjectContextSignature(prevRecord) === nextSignature) continue;
+
+ const projectId = nextRecord.project.id;
+ this.contextDirtySignatures.set(projectId, nextSignature);
+ if (projectId === this.activeProjectId) this.warmProjectContext(projectId);
+ }
+ }
+
+ /**
+ * Fire-and-forget refresh of a project's off-vault conversion-cache snapshots. Unlike
+ * {@link rematerializeContext} this NEVER touches the context-load atom, so it
+ * silently warms the disk cache without gating any composer. The materializer
+ * single-flights and cheap-skips unchanged sources, so a newly-added URL is
+ * fetched while everything else is a no-op. Skipped off-desktop (no adapter).
+ */
+ private warmProjectContext(projectId: ProjectScopeId): void {
+ if (this.disposed || projectId === GLOBAL_SCOPE) return;
+ let cwd: string;
+ try {
+ cwd = this.resolveSessionCwd(projectId);
+ } catch {
+ return; // off-desktop: no FileSystemAdapter, nothing to warm
+ }
+ void ensureProjectContextMaterialized(this.app, projectId, cwd).catch((err) =>
+ logWarn(`[AgentMode] background context warm failed for ${projectId}`, err)
+ );
+ }
+
+ /** Whether a project's materialized context is known to be stale. */
+ private isProjectContextDirty(projectId: ProjectScopeId): boolean {
+ return this.contextDirtySignatures.has(projectId);
+ }
+
+ /**
+ * Clear a project's dirty flag once a freshly-created session has captured its
+ * context — but ONLY if the signature the session actually captured (passed in,
+ * read synchronously at materialization kickoff) still equals the dirty one. A
+ * newer edit that landed after this session started materializing bumps the
+ * dirty signature, so it won't match and the project stays dirty (this session
+ * captured the OLDER sources). The lost-update guard for edit/create races.
+ */
+ private clearContextDirtyIfCaptured(projectId: ProjectScopeId, capturedSignature: string): void {
+ if (this.contextDirtySignatures.get(projectId) === capturedSignature) {
+ this.contextDirtySignatures.delete(projectId);
+ }
+ }
+
/**
* List every known Agent Mode chat as a `ChatHistoryItem`: markdown-saved
* notes (ranked using the plugin's shared in-memory `lastAccessedAt`
@@ -343,14 +586,21 @@ export class AgentSessionManager {
* by {@link LIST_SESSIONS_TIMEOUT_MS}) so chats created outside the plugin
* surface too — a backend is never spawned just to enumerate history.
*/
- async getChatHistoryItems(): Promise {
+ async getChatHistoryItems(scope?: ProjectScopeId): Promise {
const persistence = this.opts.persistenceManager;
const index = this.opts.sessionIndex;
- if (!persistence && !index) return [];
+ if (!persistence && !index) return EMPTY_HISTORY_ITEMS;
+ // Paths of saved chats backed by a live session that is flagging for
+ // attention (finished / errored / paused while backgrounded). In-memory and
+ // app-lifetime only — purely-on-disk chats stay unflagged. Applied in the
+ // map below so both the global and project views carry the cue.
+ const liveAttentionPaths = this.collectLiveAttentionPaths();
+
+ let files: TFile[] = [];
let markdownEntries: MarkdownChatEntry[] = [];
if (persistence) {
- const files = await persistence.getAgentChatHistoryFiles();
+ files = await persistence.getAgentChatHistoryFiles();
const tracker = this.plugin.getChatHistoryLastAccessedAtManager();
markdownEntries = await Promise.all(
files.map(async (file) => {
@@ -359,14 +609,50 @@ export class AgentSessionManager {
// cache-only read would leave those rows unmergeable and duplicate
// their native twins.
const ref = await this.readSessionRefFromFile(file.path);
+ const item = fileToHistoryItem(this.app, file, tracker);
return {
- item: fileToHistoryItem(this.app, file, tracker),
+ item: liveAttentionPaths.has(item.id) ? { ...item, needsAttention: true } : item,
backendId: ref?.backendId,
sessionId: ref?.sessionId,
};
})
);
}
+
+ // Project view: resolve the AUTHORITATIVE projectId per file and keep only
+ // this scope's chats. The sync `fileToHistoryItem` reads only metadataCache,
+ // which can lag right after a save or miss hidden-dir files — that would
+ // default such a chat to global and wrongly drop it from its project list.
+ // `readChatPathProjectId` hits the cache for indexed files (zero extra IO)
+ // and falls back to a one-shot adapter read only for unindexed ones.
+ if (scope && scope !== GLOBAL_SCOPE) {
+ const resolved = await Promise.all(
+ files.map(async (file, i) => {
+ const projectId =
+ (await readChatPathProjectId(this.app, file.path))?.trim() || GLOBAL_SCOPE;
+ return projectId === scope ? markdownEntries[i] : null;
+ })
+ );
+ // Drop cross-device-unresumable chats AFTER scope resolution: the resolver
+ // indexes `markdownEntries[i]` against `files`, so filtering the list before
+ // it would misalign those indices.
+ const scopedMarkdown = await this.dropNonLocalMarkdownEntries(
+ resolved.filter((entry): entry is MarkdownChatEntry => entry !== null)
+ );
+ if (!index) {
+ const items = scopedMarkdown.map((e) => e.item);
+ return items.length === 0 ? EMPTY_HISTORY_ITEMS : items;
+ }
+ // Native entries scope by the index's recorded projectId (written
+ // through from live sessions, or cwd-attributed by the sweep), so
+ // autosave-off project chats list here too — same dual-source merge as
+ // the global view, just filtered to this scope on both sides.
+ await this.refreshNativeSessionsFromBackends();
+ const scopedNative = (await index.getEntries()).filter((e) => e.projectId === scope);
+ const merged = mergeChatHistoryItems(scopedMarkdown, scopedNative);
+ return merged.length === 0 ? EMPTY_HISTORY_ITEMS : merged;
+ }
+
markdownEntries = await this.dropNonLocalMarkdownEntries(markdownEntries);
if (!index) return markdownEntries.map((e) => e.item);
@@ -375,6 +661,80 @@ export class AgentSessionManager {
return mergeChatHistoryItems(markdownEntries, nativeEntries);
}
+ /**
+ * Saved-file paths of live sessions currently flagging `needsAttention`. The
+ * path is keyed off the session's persisted file (set after its first save),
+ * matching `ChatHistoryItem.id`. Sessions that never saved (no path) or aren't
+ * flagging are skipped.
+ */
+ private collectLiveAttentionPaths(): Set {
+ const paths = new Set();
+ for (const [internalId, session] of this.sessions) {
+ if (!session.getNeedsAttention()) continue;
+ const path = this.sessionState.get(internalId)?.path;
+ if (path) paths.add(path);
+ }
+ return paths;
+ }
+
+ /**
+ * Every recent-list id this session may currently be rendered under. The ids
+ * match `ChatHistoryItem.id`: the persisted markdown path once saved, the
+ * `(backendId, sessionId)` native id before that. Deliberately BOTH when both
+ * exist: the mounted landing list is a snapshot, so right after the first
+ * autosave re-keys the chat from native id to path, the visible row may still
+ * carry the old native id — emitting both keeps `.has()` hitting whichever
+ * the row was rendered under, with no history reload. Harmless overshoot: a
+ * native id maps to the same chat and its native twin row is de-duped away.
+ *
+ * DESIGN NOTE — known limitation, deliberately deferred: a session that has
+ * neither saved nor reached the native index yet has NO row in the list at
+ * all, so its spinner/dot has nowhere to hang until the list next reloads.
+ * That's a missing row, not an id mismatch — dual ids can't help, and forcing
+ * a history reload from here would couple autosave to row visibility.
+ */
+ private recentChatIdsForSession(internalId: string, session: AgentSession): string[] {
+ const ids: string[] = [];
+ const path = this.sessionState.get(internalId)?.path;
+ if (path) ids.push(path);
+ const backendSessionId = session.getBackendSessionId();
+ if (backendSessionId) ids.push(buildNativeChatId(session.backendId, backendSessionId));
+ return ids;
+ }
+
+ /**
+ * Recent-list ids of pool sessions whose backend turn is currently
+ * `"running"`, so the landing rows can swap their relative-time chip for a
+ * spinner. Only `"running"` counts — `awaiting_permission` is surfaced via
+ * the needs-attention dot. Returns a shared module constant when empty so
+ * React consumers don't churn on a fresh `Set`.
+ */
+ getRunningChatIds(): ReadonlySet {
+ const ids = new Set();
+ for (const [internalId, session] of this.sessions) {
+ if (session.getStatus() !== "running") continue;
+ for (const id of this.recentChatIdsForSession(internalId, session)) ids.add(id);
+ }
+ return ids.size === 0 ? EMPTY_RECENT_CHAT_IDS : ids;
+ }
+
+ /**
+ * Recent-list ids of pool sessions currently flagging needs-attention, so a
+ * row's done-dot can light up the moment its backgrounded turn finishes —
+ * the live complement to the `item.needsAttention` snapshot that
+ * `getChatHistoryItems` bakes in at load time (which goes stale the moment a
+ * session finishes after the list mounted, and never covers native-only
+ * rows). Same shape and constant-on-empty contract as `getRunningChatIds`.
+ */
+ getAttentionChatIds(): ReadonlySet {
+ const ids = new Set();
+ for (const [internalId, session] of this.sessions) {
+ if (!session.getNeedsAttention()) continue;
+ for (const id of this.recentChatIdsForSession(internalId, session)) ids.add(id);
+ }
+ return ids.size === 0 ? EMPTY_RECENT_CHAT_IDS : ids;
+ }
+
/**
* Update the user-visible title of a saved chat — frontmatter `topic` for
* markdown chats, the index entry (plus the live session's label, when the
@@ -524,7 +884,10 @@ export class AgentSessionManager {
* Drop markdown chats whose backend session can't be resumed on this device
* — a chat started on another machine syncs its note (with the session id)
* but not the backend's local transcript store, so resuming it dead-ends.
- * Only hides a row when a running backend can cheaply and definitively say
+ * Resumability is probed against each chat's own scope cwd (its project
+ * folder, or the vault root for global chats), since a backend may key its
+ * transcript store by cwd. Only hides a row when a running backend can
+ * cheaply and definitively say
* the session is absent; an unknown answer (no such capability, backend not
* running, or a probe error) keeps the row so we never hide a local chat.
*
@@ -538,13 +901,28 @@ export class AgentSessionManager {
): Promise {
const adapter = this.app.vault.adapter;
if (!(adapter instanceof FileSystemAdapter)) return entries;
- const cwd = adapter.getBasePath();
+ const vaultRoot = adapter.getBasePath();
const procs = this.getRunningProcsByBackend();
const keep = await Promise.all(
entries.map(async (entry) => {
if (!entry.backendId || !entry.sessionId) return true;
const proc = procs.get(entry.backendId);
if (!proc?.sessionExistsLocally) return true;
+ // Probe with the chat's OWN scope cwd, not the vault root: a project
+ // chat's transcript lives under its project folder (Claude keys the
+ // transcript path by cwd), so probing every row with the vault root
+ // would wrongly report a local project chat as non-resumable and hide
+ // it. Resolving per entry keeps both the flat global view and a project
+ // view correct. A scope that no longer resolves (deleted project) falls
+ // back to keeping the row — never hide a chat on an uncertain answer.
+ let cwd: string;
+ try {
+ const projectId =
+ (await readChatPathProjectId(this.app, entry.item.id))?.trim() || GLOBAL_SCOPE;
+ cwd = resolveScopeCwd(vaultRoot, projectId);
+ } catch {
+ return true;
+ }
try {
return await proc.sessionExistsLocally({ sessionId: entry.sessionId, cwd });
} catch {
@@ -566,11 +944,12 @@ export class AgentSessionManager {
}
/**
- * Merge one backend's `listSessions` result into the index. Filters to
- * this vault's cwd (agent-side cwd filtering is not trusted — a stray
- * session from another vault must never leak into this vault's history),
- * skips the preloader's probe session, and requires a real title so the
- * sweep can't surface empty placeholder sessions.
+ * Merge one backend's `listSessions` result into the index. Keeps sessions
+ * whose cwd is this vault's root (global scope) or a known project folder
+ * (attributed to that project) — agent-side cwd filtering is not trusted,
+ * and a stray session from another vault must never leak into this vault's
+ * history. Skips the preloader's probe session and requires a real title so
+ * the sweep can't surface empty placeholder sessions.
*/
private async sweepNativeSessions(
backendId: BackendId,
@@ -599,7 +978,12 @@ export class AgentSessionManager {
const now = Date.now();
const discovered = [];
for (const s of sessions) {
- if (!isSameCwd(s.cwd, vaultBasePath)) continue;
+ const inVaultRoot = isSameCwd(s.cwd, vaultBasePath);
+ // A session run inside a materialized project folder belongs to that
+ // project's history; anything matching neither the vault root nor a
+ // known project folder is another vault's session.
+ const projectId = inVaultRoot ? undefined : resolveProjectIdForCwd(vaultBasePath, s.cwd);
+ if (!inVaultRoot && !projectId) continue;
if (probeSessionId && s.sessionId === probeSessionId) continue;
// A live fan-out sub-session is ephemeral: skip it even before its
// async tombstone lands, so a sweep racing the in-flight turn can't
@@ -615,6 +999,7 @@ export class AgentSessionManager {
title,
createdAtMs: timestamp,
lastAccessedAtMs: timestamp,
+ projectId,
});
}
if (discovered.length > 0) await index.mergeDiscoveredSessions(discovered);
@@ -630,24 +1015,35 @@ export class AgentSessionManager {
throw new Error("AgentSessionManager has been shut down");
}
const active = this.getActiveSession();
- if (active && active.getStatus() !== "closed") return active;
+ // Only reuse the active session when it belongs to the current scope —
+ // after `enterProject` the prior scope's session may still be pointed at by
+ // `activeSessionId` until this seeds a fresh one for the new scope.
+ if (active && active.projectId === this.activeProjectId && active.getStatus() !== "closed") {
+ return active;
+ }
// Dedupe rapid auto-spawn callers (e.g. the router effect re-running
// before the first create has populated the pool) so we don't seed two
- // sessions when one was asked for.
- if (this.firstSessionPromise) return this.firstSessionPromise;
- this.firstSessionPromise = this.createSession();
+ // sessions when one was asked for. Keyed per scope so two scopes spawning
+ // at once don't collapse into one shared session.
+ const scope = this.activeProjectId;
+ const pending = this.firstSessionPromiseByScope.get(scope);
+ if (pending) return pending;
+ const promise = this.createSession(undefined, scope);
+ this.firstSessionPromiseByScope.set(scope, promise);
try {
- return await this.firstSessionPromise;
+ return await promise;
} finally {
- this.firstSessionPromise = null;
+ this.firstSessionPromiseByScope.delete(scope);
}
}
/**
* Spawn a fresh `AgentSession`. Lazily starts the requested backend on its
- * first call. The new session becomes the active one. `backendId` defaults
- * to `settings.agentMode.activeBackend` (the model-picker keeps that in
- * sync with the user's most recently selected default model).
+ * first call. The new session becomes the active one *when its scope is the
+ * current one* (a background/restart create stays parked). `backendId`
+ * defaults to `settings.agentMode.activeBackend` (the model-picker keeps that
+ * in sync with the user's most recently selected default model). `projectId`
+ * defaults to the active scope and is bound immutably onto the session.
*
* The new session's initial (model, effort) defaults to the persisted
* default for `backendId` via `getDefaultSelection`. Pass `seedSelection`
@@ -656,19 +1052,47 @@ export class AgentSessionManager {
*/
async createSession(
backendId?: BackendId,
+ projectId: ProjectScopeId = this.activeProjectId,
seedSelection?: ModelSelection
): Promise {
if (this.disposed) {
throw new Error("AgentSessionManager has been shut down");
}
- const adapter = this.app.vault.adapter;
- if (!(adapter instanceof FileSystemAdapter)) {
- throw new Error("Agent Mode requires desktop Obsidian (FileSystemAdapter).");
- }
- const vaultBasePath = adapter.getBasePath();
-
const resolvedId = backendId ?? getSettings().agentMode?.activeBackend ?? "opencode";
+
+ // Read the live record once: it drives both the AGENTS.md mirror below and
+ // the landing-capture signature recorded after the session is built, so the
+ // two agree on the exact config this session bakes in.
+ const projectRecord =
+ projectId === GLOBAL_SCOPE ? undefined : getCachedProjectRecordById(projectId);
+ const landingCaptureSignature = projectRecord
+ ? getProjectLandingCaptureSignature(projectRecord)
+ : undefined;
+
+ // Materialize the project's AGENTS.md mirror from project.md BEFORE resolving cwd, so a
+ // cwd-instruction backend (codex/opencode) discovers the instruction file on its first
+ // session. This is the sole correctness guarantee for an old project.md-only project.
+ // Never throws (degrades gracefully); skipped for GLOBAL_SCOPE and for claude (which gets
+ // the instruction injected in-process, no file needed).
+ if (projectRecord && CWD_INSTRUCTION_BACKENDS.has(resolvedId)) {
+ await ensureAgentsMirror(this.app, projectRecord);
+ }
+
+ // Resolves the scope's cwd (vault root for global, project folder otherwise)
+ // and validates desktop/orphaned up front, before any pending-create state
+ // is mutated.
+ const cwd = this.resolveSessionCwd(projectId);
+
+ // Kick off context materialization WITHOUT blocking session creation: the
+ // session must become visible immediately so the composer's loading card +
+ // send-gate render while prefetch runs (§3.1.9). The returned promise is
+ // threaded into the session, which awaits it right before `newSession`.
+ // Skipped for GLOBAL_SCOPE — no promise, no atom write, byte-identical to a
+ // context-free create. Never rejects (degrades to empty roots).
+ const contextReady =
+ projectId === GLOBAL_SCOPE ? undefined : this.beginContextMaterialization(projectId, cwd);
+
const descriptor = this.resolveDescriptor(resolvedId);
this.pendingCreates++;
@@ -714,19 +1138,22 @@ export class AgentSessionManager {
// disk on the next preload, so adopting that session as the chat would
// replay the previous conversation's transcript and auto-title into a
// supposedly fresh chat. `AgentSession.start` runs `newSession` on the
- // (warm or cold) proc; the probe's state still seeds the picker so it
+ // (warm or cold) proc at the resolved scope cwd, threading the project
+ // scope + context roots; the probe's state still seeds the picker so it
// doesn't blink while that round-trip is in flight.
const session = AgentSession.start({
backend,
- cwd: vaultBasePath,
+ cwd,
internalId: uuidv4(),
backendId: resolvedId,
+ projectId,
defaultModelSelection: resolvedSeed,
initialCachedState: warm?.state ?? this.preloader.getCachedBackendState(resolvedId),
getDescriptor: () => this.opts.resolveDescriptor(resolvedId),
runFanoutTurn: (input) => this.runFanoutTurn(input),
getDisplayName: (backendId) => this.resolveDescriptor(backendId).displayName,
getApp: () => this.app,
+ contextReady,
});
if (warm) {
logInfo(
@@ -735,7 +1162,27 @@ export class AgentSessionManager {
}
this.sessions.set(session.internalId, session);
this.chatUIStates.set(session.internalId, new AgentChatUIState(session));
- this.activeSessionId = session.internalId;
+ // Record what this project landing captured so re-entry can tell a current
+ // landing from one that baked in stale config (incl. a System-Prompt-only
+ // edit the materialization dirty flag ignores). Global sessions capture no
+ // project config, so they get no entry (and are never reused as landings).
+ if (landingCaptureSignature) {
+ this.landingCaptureSignatures.set(session.internalId, landingCaptureSignature);
+ } else {
+ this.landingCaptureSignatures.delete(session.internalId);
+ }
+ // A fresh id is never detached; clear defensively so a new session can't
+ // inherit a stale hidden-from-strip flag.
+ this.detachedFromTabIds.delete(session.internalId);
+ // Always the scope's newest MRU. But only steal the global active pointer
+ // when this session's scope is still the current one — a slow auto-spawn or
+ // a restart that replaces a *background* tab must not yank the user out of a
+ // scope they've since switched to (preserves `active.projectId ===
+ // activeProjectId`).
+ this.lastActiveByScope.set(projectId, session.internalId);
+ if (projectId === this.activeProjectId) {
+ this.activeSessionId = session.internalId;
+ }
this.attachAutoSave(session);
this.attachModelCacheSync(session);
this.attachAttentionTracking(session);
@@ -749,6 +1196,22 @@ export class AgentSessionManager {
// `ready` is already resolved, so the chain runs on the next microtask.
void session.ready
.then(async () => {
+ // Reaching here means the session is fully ready — it captured its context
+ // and opened its backend session (a failed startup rejects into `.catch`
+ // and never runs this). A fresh session's `ready` resolves only after
+ // `initialize()` has already awaited `contextReady`, so the await below is
+ // an already-settled microtask, not a fresh network wait — it can't stall
+ // `finishPendingCreate`. Clear the project's dirty flag FIRST, before any
+ // optional backend config that could hang and stall it. Clear only for the
+ // source revision the materializer actually captured (`contextSignature`):
+ // a newer edit mid-flight bumped the dirty signature, so it won't match and
+ // stays dirty (lost-update guard); a failed materialize carries no signature.
+ if (contextReady) {
+ const result = await contextReady;
+ if (!this.disposed && result.contextSignature !== undefined) {
+ this.clearContextDirtyIfCaptured(projectId, result.contextSignature);
+ }
+ }
if (descriptor.applyInitialSessionConfig) {
try {
await descriptor.applyInitialSessionConfig(session, getSettings(), resolvedSeed);
@@ -791,6 +1254,583 @@ export class AgentSessionManager {
return descriptor;
}
+ /**
+ * Absolute cwd for a session in `projectId`'s scope. Single source of truth
+ * for every session-construction site. Throws on a non-desktop adapter or an
+ * orphaned project (missing record) — the latter must never silently fall
+ * back to the vault root.
+ */
+ private resolveSessionCwd(projectId: ProjectScopeId): string {
+ const adapter = this.app.vault.adapter;
+ if (!(adapter instanceof FileSystemAdapter)) {
+ throw new Error("Agent Mode requires desktop Obsidian (FileSystemAdapter).");
+ }
+ return resolveScopeCwd(adapter.getBasePath(), projectId);
+ }
+
+ /**
+ * Start (but do NOT await) materializing a project's context, returning a
+ * promise of the extra searchable roots. Caller has already done config
+ * migration + cwd resolution, so the materializer sees the post-migration
+ * record and resolved cwd. Only ever invoked for a non-global scope.
+ *
+ * Publishes the blocking load state SYNCHRONOUSLY (before the session even
+ * appears) so the composer gates send the instant the tab renders, then flips
+ * to `done`/`error` and stores the result for {@link getProjectProfile} once
+ * the materializer settles. NEVER rejects — failure degrades to an empty
+ * result so the session's `newSession` (which awaits this) is never blocked.
+ *
+ * Resolves the full {@link ContextMaterializationResult} (searchable roots +
+ * the optional inline `` block); the session captures both
+ * before opening. Progress/counts are NOT carried here — they publish
+ * separately via {@link agentProjectContextLoadAtom}.
+ */
+ private beginContextMaterialization(
+ projectId: ProjectScopeId,
+ cwd: string,
+ forceRetryFailed?: boolean
+ ): Promise {
+ // Accumulate counts so every publish carries the latest of all three steps
+ // (resolve / prefetch / parse), not just the one that fired last.
+ const counts: {
+ resolved?: number;
+ prefetch?: ContextLoadStepCount;
+ parsed?: ContextLoadStepCount;
+ } = {};
+ // Per-source state mirroring the legacy CAG tracker: `processingSources` is
+ // the live "in flight" set, `failedSources` accrues settled failures. Both
+ // publish incrementally so the popover renders a true queue; the materializer
+ // sends a final `failures` list that reconciles `failedSources` at the end.
+ let failedSources: FailedItem[] = [];
+ let processingSources: AgentInFlightSource[] = [];
+ // The latest step phase, so item-lifecycle publishes (which carry no phase of
+ // their own) keep the loading card on the current step.
+ let stepPhase: "resolve" | "prefetch" | "parse" = "resolve";
+
+ // Own the atom only when no concurrent run is already driving it. The
+ // materializer single-flights, so a second caller would otherwise (a) seed a
+ // count-less "resolve" over the owner's live counts and (b) publish a
+ // count-less terminal "done" during the owner's linger window. Letting only
+ // the flight owner publish keeps the projectId-keyed atom coherent.
+ const prior = settingsStore.get(agentProjectContextLoadAtom)[projectId];
+ const ownsPublish = !prior?.blocking;
+
+ // Seed the blocking state synchronously (before the session even appears) so
+ // the composer gates send the instant the tab renders.
+ if (ownsPublish) {
+ this.setContextLoadState(projectId, { phase: "resolve", blocking: true });
+ }
+
+ // Re-emit the running state with the current counts + per-source sets. Empty
+ // `processingSources` publishes as `undefined` (the adapter falls back to a
+ // frozen empty), matching the `retryingSources` referential-stability pattern.
+ const publishProgress = () => {
+ this.setContextLoadState(projectId, {
+ phase: stepPhase,
+ blocking: true,
+ ...counts,
+ failedSources,
+ processingSources: processingSources.length > 0 ? processingSources : undefined,
+ });
+ };
+
+ const onProgress = ownsPublish
+ ? (progress: ContextMaterializeProgress) => {
+ switch (progress.phase) {
+ case "failures":
+ // Final reconciliation of the run's failures (data-only; the
+ // terminal `done` publish carries the authoritative list).
+ failedSources = progress.failures.map(toFailedItem);
+ return;
+ case "itemStart":
+ processingSources = addProcessingSource(processingSources, progress.item);
+ failedSources = failedSources.filter((f) => !failedItemIsSource(f, progress.item));
+ publishProgress();
+ return;
+ case "itemFailed":
+ processingSources = removeProcessingSource(processingSources, progress.item);
+ failedSources = upsertFailedItem(failedSources, toFailedItem(progress.failure));
+ publishProgress();
+ return;
+ case "itemSettled":
+ processingSources = removeProcessingSource(processingSources, progress.item);
+ publishProgress();
+ return;
+ case "resolve":
+ counts.resolved = progress.resolved;
+ stepPhase = "resolve";
+ publishProgress();
+ return;
+ case "prefetch":
+ counts.prefetch = { done: progress.done, total: progress.total };
+ stepPhase = "prefetch";
+ publishProgress();
+ return;
+ case "parse":
+ counts.parsed = { done: progress.done, total: progress.total };
+ stepPhase = "parse";
+ publishProgress();
+ return;
+ }
+ }
+ : undefined;
+
+ return ensureProjectContextMaterialized(this.app, projectId, cwd, onProgress, forceRetryFailed)
+ .then((ctx) => {
+ if (ownsPublish) {
+ // Always carry `failedSources` (empty when clean) so a prior run's
+ // failures never linger; the run is done, so nothing is processing.
+ this.setContextLoadState(projectId, {
+ phase: "done",
+ blocking: false,
+ ...counts,
+ failedSources,
+ });
+ }
+ return ctx;
+ })
+ .catch((err) => {
+ // The materializer's contract is never-reject; this guards a contract
+ // breach. Publish as a completed run with a single synthetic failure
+ // (not a distinct error phase — there is no "whole context" failure state).
+ logWarn(`[AgentMode] project context materialize failed for ${projectId}; continuing`, err);
+ if (ownsPublish) {
+ this.setContextLoadState(projectId, {
+ phase: "done",
+ blocking: false,
+ failedSources: [{ path: "Project context", type: "nonMd", error: err2String(err) }],
+ });
+ }
+ return EMPTY_CONTEXT_MATERIALIZATION_RESULT;
+ });
+ }
+
+ /** Publish a project's context-load state to the projectId-keyed atom. */
+ private setContextLoadState(projectId: string, state: AgentProjectContextLoadState): void {
+ settingsStore.set(agentProjectContextLoadAtom, (prev) => ({ ...prev, [projectId]: state }));
+ }
+
+ /**
+ * Re-run a project's context materialization on demand — the status popover's
+ * "Retry" action. Fire-and-forget: progress/failures republish through
+ * {@link agentProjectContextLoadAtom} exactly like a session-create run. Passes
+ * `forceRetryFailed` so known-bad sources are re-fetched instead of honoring
+ * their persisted failure markers (the automatic path cheap-skips them). A
+ * no-op for the global scope (no per-project context).
+ *
+ * Early-exits while a run already owns the load atom (`blocking`), mirroring
+ * {@link rematerializeSource}: joining the in-flight session-create run would
+ * let its cheap-skip swallow the force, so the user's Retry would do nothing.
+ * Returns whether a real forced run started (so the caller can defer the
+ * post-retry landing refresh).
+ */
+ rematerializeContext(projectId: ProjectScopeId): boolean {
+ if (projectId === GLOBAL_SCOPE) return false;
+ // A session-create run owns the atom while blocking; don't fold the force in.
+ if (settingsStore.get(agentProjectContextLoadAtom)[projectId]?.blocking) return false;
+ let cwd: string;
+ try {
+ cwd = this.resolveSessionCwd(projectId);
+ } catch (err) {
+ // Off-desktop (no FileSystemAdapter): surface as a completed run with a
+ // single synthetic failure, mirroring the materializer's fatal path.
+ this.setContextLoadState(projectId, {
+ phase: "done",
+ blocking: false,
+ failedSources: [{ path: "Project context", type: "nonMd", error: err2String(err) }],
+ });
+ return false;
+ }
+ // Start the forced pass SYNCHRONOUSLY so it claims the materializer's
+ // single-flight slot before this returns: a background warm (a source edit)
+ // runs through that guard without owning the blocking atom, so the check
+ // above can't see it — but the forced run supersedes it (see
+ // {@link ensureProjectContextMaterialized}), and any landing refresh the
+ // returned `true` triggers then joins THIS forced run, not the stale warm.
+ void this.beginContextMaterialization(projectId, cwd, true);
+ // Reason: report whether a real run started so the caller can defer the
+ // post-retry landing refresh (skipped for the no-op scopes above).
+ return true;
+ }
+
+ /**
+ * Re-materialize a SINGLE source on demand — the per-row "Retry" in the
+ * Content Conversion panel. Ignored while a full run is in flight (the UI hides
+ * Retry then, and the running pass already re-attempts every source). On
+ * settle, drops this source's stale failure from the load atom — re-adding it
+ * only if the retry failed again — so the panel reflects the new outcome
+ * without a whole-project re-run. A no-op for the global scope.
+ *
+ * Returns whether a retry actually ran to completion — `false` when it was
+ * skipped (global scope, a full run owns the atom, or this source is already
+ * retrying), so the caller can avoid a premature post-retry refresh.
+ */
+ async rematerializeSource(
+ projectId: ProjectScopeId,
+ item: { kind: MaterializedSourceType; source: string }
+ ): Promise {
+ if (projectId === GLOBAL_SCOPE) return false;
+ const before = settingsStore.get(agentProjectContextLoadAtom)[projectId];
+ // A full run owns the atom while blocking; don't race it.
+ if (before?.blocking) return false;
+
+ const matchesItem = (f: FailedItem) =>
+ f.path === item.source && (item.kind === "file" ? f.type === "nonMd" : f.type === item.kind);
+ const sameSource = (r: { kind: MaterializedSourceType; source: string }) =>
+ r.kind === item.kind && r.source === item.source;
+
+ // Single-flight per source: if this source's retry is already in flight, a
+ // second click would fire a duplicate materialize and let the first finisher
+ // clear the spinner early — so bail rather than re-fire.
+ const beforeRetrying = before?.retryingSources ?? [];
+ if (beforeRetrying.some(sameSource)) return false;
+
+ // Optimistically mark this source as retrying so its row flips to a spinner
+ // immediately — the click has visible feedback even if the retry fails again.
+ // Drop its stale failure now; re-add below only if the retry fails.
+ this.setContextLoadState(projectId, {
+ ...(before ?? { phase: "done" as const }),
+ blocking: false,
+ failedSources: (before?.failedSources ?? []).filter((f) => !matchesItem(f)),
+ retryingSources: [...beforeRetrying, item],
+ });
+
+ // The single-source retry serializes with any in-flight full run on the
+ // shared per-artifact lock (keyed by snapshot file name), and shared snapshots
+ // are never reconciled — so a concurrent warm can no longer reap the snapshot
+ // this Retry writes. No pre-join is needed.
+ const failures = await materializeProjectContextSource(this.app, projectId, item);
+
+ const prev = settingsStore.get(agentProjectContextLoadAtom)[projectId];
+ // A full run may have started during our await — it now owns the atom. Bail
+ // so we don't clobber its `blocking` send-gate / progress with a stale write.
+ if (prev?.blocking) return false;
+ const others = (prev?.failedSources ?? []).filter((f) => !matchesItem(f));
+ const remainingRetrying = (prev?.retryingSources ?? []).filter((r) => !sameSource(r));
+ this.setContextLoadState(projectId, {
+ ...(prev ?? { phase: "done" as const }),
+ blocking: false,
+ retryingSources: remainingRetrying.length > 0 ? remainingRetrying : undefined,
+ failedSources: [...others, ...failures.map(toFailedItem)],
+ });
+ return true;
+ }
+
+ /** The scope the active session belongs to ({@link GLOBAL_SCOPE} or a project id). */
+ getActiveProjectId(): ProjectScopeId {
+ return this.activeProjectId;
+ }
+
+ /**
+ * Sessions belonging to `projectId`, in tab order. Feeds the scoped tab strip.
+ * Distinct from {@link getSessions} (which stays full-set for draft-prune /
+ * auto-spawn). Returns a shared frozen empty array when the scope has none.
+ */
+ getSessionsForScope(projectId: ProjectScopeId): AgentSession[] {
+ const scoped: AgentSession[] = [];
+ for (const session of this.sessions.values()) {
+ if (session.projectId !== projectId) continue;
+ if (this.detachedFromTabIds.has(session.internalId)) continue;
+ scoped.push(session);
+ }
+ return scoped.length === 0 ? EMPTY_SESSIONS : scoped;
+ }
+
+ /** Session ids belonging to `projectId`, in tab order. */
+ private getSessionIdsForScope(projectId: ProjectScopeId): string[] {
+ const ids: string[] = [];
+ for (const session of this.sessions.values()) {
+ if (session.projectId !== projectId) continue;
+ if (this.detachedFromTabIds.has(session.internalId)) continue;
+ ids.push(session.internalId);
+ }
+ return ids;
+ }
+
+ /**
+ * Switch the active scope to `projectId` and surface one of its sessions:
+ * its MRU (if still alive) → any existing session in the scope → a freshly
+ * auto-spawned one. Orphaned ids (project deleted) show a Notice and are
+ * rejected without touching the active scope or cwd (no recovery UI yet).
+ * `exitProject` is just re-entering {@link GLOBAL_SCOPE}.
+ */
+ async enterProject(projectId: ProjectScopeId): Promise {
+ if (this.disposed) return;
+ if (projectId !== GLOBAL_SCOPE && !getCachedProjectRecordById(projectId)) {
+ new Notice("This project no longer exists. Restore it to open its chats.");
+ return;
+ }
+ // Already here with a live in-scope session — nothing to restore.
+ const current = this.getActiveSession();
+ if (
+ projectId === this.activeProjectId &&
+ current &&
+ current.projectId === projectId &&
+ current.getStatus() !== "closed"
+ ) {
+ return;
+ }
+
+ // Snapshot the scope this entry replaces BEFORE the optimistic switch, so a
+ // rejected auto-spawn below can restore it (see `spawnEnteredScopeOrRollback`).
+ const previousActiveProjectId = this.activeProjectId;
+ const previousActiveSessionId = this.activeSessionId;
+ this.parkActiveScope();
+ this.activeProjectId = projectId;
+
+ // A project scope opens as a FRESH visit: its prior conversational chats
+ // move to chat history (detached from the tab strip — not closed, so a
+ // backgrounded turn keeps running and stays visible via the history row's
+ // spinner/done-dot). A never-used empty landing tab is reused instead of
+ // stacking a new blank tab on every visit. The global workspace keeps its
+ // restore-the-last-tab behavior (it's the implicit scope `exitProject`
+ // returns to, not a project the user deliberately re-opens).
+ if (projectId !== GLOBAL_SCOPE) {
+ // Reuse an empty landing only when it's safe: not dirty (its
+ // `` would be stale — materialization-only, see
+ // `contextDirtySignatures`) AND its creation-time capture still matches the
+ // live project config (`pickReusableLandingSession` checks the
+ // landing-capture signature, which also covers a System-Prompt-only edit the
+ // dirty flag ignores). When we instead spawn fresh, detach the project's
+ // empty landings too so a stale blank one can't linger in the strip; when we
+ // reuse, leave them so the adopted tab survives.
+ const dirty = this.isProjectContextDirty(projectId);
+ const reusable = dirty ? null : this.pickReusableLandingSession(projectId);
+ this.detachSessionsForProjectEntry(projectId, { includeEmpty: !reusable });
+ if (reusable) {
+ this.detachedFromTabIds.delete(reusable.internalId);
+ this.activeSessionId = reusable.internalId;
+ this.lastActiveByScope.set(projectId, reusable.internalId);
+ reusable.clearNeedsAttention();
+ this.notify();
+ this.touchProjectUsage(projectId);
+ return;
+ }
+ this.activeSessionId = null;
+ this.notify();
+ await this.spawnEnteredScopeOrRollback(
+ projectId,
+ previousActiveProjectId,
+ previousActiveSessionId
+ );
+ this.touchProjectUsage(projectId);
+ return;
+ }
+
+ const restored = this.restoreScopeActiveSession(projectId);
+ if (restored) {
+ this.activeSessionId = restored.internalId;
+ this.lastActiveByScope.set(projectId, restored.internalId);
+ restored.clearNeedsAttention();
+ this.notify();
+ this.touchProjectUsage(projectId);
+ return;
+ }
+
+ // No reusable session in this scope — drop the stale cross-scope pointer
+ // (keeps the `active.projectId === activeProjectId` invariant) and spawn.
+ this.activeSessionId = null;
+ this.notify();
+ await this.getOrCreateActiveSession();
+ // Reason: count as a "use" only after the spawn resolves — a failed spawn
+ // (await throws) must not bump MRU.
+ this.touchProjectUsage(projectId);
+ }
+
+ /**
+ * Detach a project scope's prior tabs on re-entry — they stay live in the pool
+ * and listed in chat history. Conversational chats (with user-visible
+ * messages) always detach. Empty landing tabs detach only when
+ * `includeEmpty` is set (the caller is spawning a fresh session, so any
+ * existing empty landing is stale/unusable and must not linger in the strip);
+ * otherwise they're left attached so the reused one stays the visit's tab (see
+ * {@link pickReusableLandingSession}).
+ *
+ * DESIGN NOTE — detached sessions are deliberately kept ALIVE (a backgrounded
+ * turn keeps running; the history row shows its spinner/done-dot) and are NOT
+ * evicted here, so repeated project re-entry accumulates idle detached sessions
+ * for the app's lifetime. Bounding the idle-detached class — an LRU cap that
+ * never evicts running/awaiting sessions — needs a per-session backend close
+ * primitive the backends don't expose yet, so it is out of scope for the
+ * project-workspace landing. Known limitation, deliberately deferred
+ * (obsidian-copilot-preview#164; the missing close primitive is
+ * obsidian-copilot-preview#178). If a future review flags this again, point
+ * them here.
+ */
+ private detachSessionsForProjectEntry(
+ projectId: ProjectScopeId,
+ opts: { includeEmpty: boolean }
+ ): void {
+ for (const session of this.sessions.values()) {
+ if (session.projectId !== projectId) continue;
+ if (session.getStatus() === "closed") continue;
+ if (!opts.includeEmpty && !session.hasUserVisibleMessages()) continue;
+ this.detachedFromTabIds.add(session.internalId);
+ }
+ }
+
+ /**
+ * The scope's reusable empty landing tab — a live, never-messaged session that
+ * a fresh visit can adopt instead of spawning a new blank one. A candidate must
+ * also have captured the CURRENT project config (matching landing-capture
+ * signature); one that baked in stale config is skipped. Prefers the scope's
+ * MRU; falls back to the most recent matching session. `null` means the caller
+ * should spawn fresh.
+ */
+ private pickReusableLandingSession(projectId: ProjectScopeId): AgentSession | null {
+ const expected = this.currentLandingCaptureSignature(projectId);
+ if (!expected) return null;
+ const isReusable = (session: AgentSession): boolean =>
+ this.isReusableLandingShell(session, projectId) &&
+ this.landingCaptureSignatures.get(session.internalId) === expected;
+
+ const mruId = this.lastActiveByScope.get(projectId);
+ const mru = mruId ? this.sessions.get(mruId) : undefined;
+ if (mru && isReusable(mru)) return mru;
+
+ let fallback: AgentSession | null = null;
+ for (const session of this.sessions.values()) {
+ if (isReusable(session)) fallback = session;
+ }
+ return fallback;
+ }
+
+ /**
+ * Whether a session is a clean slate to adopt as a landing — idle/starting and
+ * never messaged. An "error" landing (backend never opened) or one mid-turn
+ * ("running"/"awaiting_permission") is not, so the caller should spawn fresh.
+ * This is the shell check ONLY; capture-freshness is gated separately.
+ */
+ private isReusableLandingShell(session: AgentSession, projectId: ProjectScopeId): boolean {
+ return (
+ session.projectId === projectId &&
+ (session.getStatus() === "idle" || session.getStatus() === "starting") &&
+ !session.hasUserVisibleMessages()
+ );
+ }
+
+ /** The live project record's landing-capture signature, or null off-project. */
+ private currentLandingCaptureSignature(projectId: ProjectScopeId): string | null {
+ if (projectId === GLOBAL_SCOPE) return null;
+ const record = getCachedProjectRecordById(projectId);
+ return record ? getProjectLandingCaptureSignature(record) : null;
+ }
+
+ /**
+ * Record a successful enter of `projectId` as its most-recent use, mirroring
+ * chat-mode {@link ProjectManager.switchProject}. Skips {@link GLOBAL_SCOPE}
+ * (the implicit workspace `exitProject` returns to) and any scope that is no
+ * longer current — the spawn-path caller touches after an `await`, so a
+ * concurrent `enterProject` may have moved the active scope on in the meantime.
+ */
+ private touchProjectUsage(projectId: ProjectScopeId): void {
+ // Reason: the spawn path touches after `await getOrCreateActiveSession()`;
+ // if the user switched scopes (or we were disposed) during that await, the
+ // project they LEFT must not be bumped to MRU top. Only credit the scope
+ // we're actually still in. The restored path passes trivially — there
+ // `activeProjectId` already equals `projectId` when this is called.
+ if (this.disposed || projectId === GLOBAL_SCOPE || this.activeProjectId !== projectId) return;
+ // Reason: MRU feedback only — fire-and-forget so the throttled frontmatter
+ // write never blocks the scope switch; touchProjectLastUsed logs+swallows
+ // its own failures.
+ void ProjectFileManager.getInstance(this.app).touchProjectLastUsed(projectId);
+ }
+
+ /** Leave the current project and return to the global workspace. */
+ async exitProject(): Promise {
+ await this.enterProject(GLOBAL_SCOPE);
+ }
+
+ /** Record the current active session as its scope's MRU before a scope switch. */
+ private parkActiveScope(): void {
+ const active = this.getActiveSession();
+ if (active && active.getStatus() !== "closed") {
+ this.lastActiveByScope.set(active.projectId, active.internalId);
+ }
+ }
+
+ /**
+ * Pick the session to surface when entering `projectId`: its recorded MRU if
+ * still alive, else the last existing session in the scope. `null` means the
+ * scope is empty and the caller should auto-spawn.
+ */
+ private restoreScopeActiveSession(projectId: ProjectScopeId): AgentSession | null {
+ const mruId = this.lastActiveByScope.get(projectId);
+ if (mruId) {
+ const mru = this.sessions.get(mruId);
+ if (mru && mru.getStatus() !== "closed") return mru;
+ }
+ const scoped = this.getSessionsForScope(projectId);
+ return scoped.length > 0 ? scoped[scoped.length - 1] : null;
+ }
+
+ /**
+ * Park the current scope and point `activeProjectId` at `projectId` without
+ * restoring/spawning a session — used by history load, which creates the
+ * specific saved session itself right after.
+ */
+ private setActiveScope(projectId: ProjectScopeId): void {
+ if (projectId === this.activeProjectId) return;
+ this.parkActiveScope();
+ this.activeProjectId = projectId;
+ }
+
+ /**
+ * Undo the optimistic scope switch a history load performs before it has a
+ * session, when the resume/create that follows rejects. A history load calls
+ * `setActiveScope` to point `activeProjectId` at the chat's scope (so the new
+ * session activates there) while `activeSessionId` still references the
+ * previous scope's session; a failed load would otherwise strand the manager
+ * with `getActiveSession().projectId !== activeProjectId` until the user
+ * manually switches scopes. Only roll back if we're still parked in the scope
+ * we switched to — a concurrent scope switch during the awaited backend spawn
+ * means the user has moved on, and forcing them back would reintroduce the
+ * very race `createSession`'s activation guard already avoids.
+ */
+ private rollbackHistoryLoadScope(
+ attemptedProjectId: ProjectScopeId,
+ previousProjectId: ProjectScopeId
+ ): void {
+ if (this.activeProjectId === attemptedProjectId) {
+ this.activeProjectId = previousProjectId;
+ }
+ }
+
+ /**
+ * Auto-spawn a just-entered project scope's first session, undoing the
+ * optimistic scope switch if the spawn rejects (e.g. a missing backend binary).
+ * By this point `enterProject` has parked the prior scope, pointed
+ * `activeProjectId` at the new scope, detached the new scope's tabs, and nulled
+ * `activeSessionId`. The callers only surface a Notice, so without this a
+ * rejected spawn would strand the manager in a project scope with no active
+ * chat. Restores the previous scope's `activeProjectId` + active-session pointer
+ * and re-notifies, then rethrows so the caller still reports the failure.
+ *
+ * Guarded on still being parked in the attempted scope: a concurrent
+ * `enterProject` during the awaited spawn means the user moved on, and forcing
+ * them back would clobber that newer switch (mirrors
+ * {@link rollbackHistoryLoadScope}). The detached tabs are intentionally left
+ * detached — they belong to the project we failed to enter, are invisible from
+ * the restored scope, and a later successful entry re-detaches them anyway as a
+ * fresh visit.
+ */
+ private async spawnEnteredScopeOrRollback(
+ attemptedProjectId: ProjectScopeId,
+ previousProjectId: ProjectScopeId,
+ previousActiveSessionId: string | null
+ ): Promise {
+ try {
+ await this.getOrCreateActiveSession();
+ } catch (err) {
+ if (this.activeProjectId === attemptedProjectId) {
+ this.activeProjectId = previousProjectId;
+ this.activeSessionId = previousActiveSessionId;
+ this.notify();
+ }
+ throw err;
+ }
+ }
+
setDefaultBackend(backendId: BackendId): void {
if (getSettings().agentMode?.activeBackend === backendId) return;
setSettings((cur) => ({
@@ -1121,10 +2161,11 @@ export class AgentSessionManager {
async closeSession(id: string): Promise {
const session = this.sessions.get(id);
if (!session) return;
- // Capture the closed tab's index BEFORE delete so we can pick the
- // neighbor that currently sits to its right.
- const idsBefore = Array.from(this.sessions.keys());
- const closedIdx = idsBefore.indexOf(id);
+ const closedScope = session.projectId;
+ // Capture the closed tab's index WITHIN ITS SCOPE before delete so the
+ // neighbour pick stays in-scope (never jumps the user to another project).
+ const scopeIdsBefore = this.getSessionIdsForScope(closedScope);
+ const closedIdx = scopeIdsBefore.indexOf(id);
try {
await session.cancel();
} catch (e) {
@@ -1141,20 +2182,48 @@ export class AgentSessionManager {
this.detachAutoSave(id);
this.sessions.delete(id);
this.chatUIStates.delete(id);
+ this.landingCaptureSignatures.delete(id);
+ this.detachedFromTabIds.delete(id);
+ // Drop the closed session from its scope's MRU so a later enter can't
+ // resurrect a dead id.
+ if (this.lastActiveByScope.get(closedScope) === id) {
+ this.lastActiveByScope.delete(closedScope);
+ }
if (this.activeSessionId === id) {
- const remaining = Array.from(this.sessions.keys());
- this.activeSessionId =
- remaining.length === 0 ? null : remaining[Math.min(closedIdx, remaining.length - 1)];
+ const scopeIdsAfter = this.getSessionIdsForScope(closedScope);
+ const nextId = pickScopeNeighbor(
+ scopeIdsAfter,
+ closedIdx,
+ this.lastActiveByScope.get(closedScope)
+ );
+ this.activeSessionId = nextId;
+ if (nextId) this.lastActiveByScope.set(closedScope, nextId);
+ // `activeProjectId` stays `closedScope` even when it's now empty — never
+ // silently jump the active scope on close.
}
this.notify();
}
- /** Move the active pointer to `id`. No-op if `id` is unknown. */
+ /**
+ * Move the active pointer to `id`. No-op if `id` is unknown. When the target
+ * lives in a different scope, the active scope follows it (cross-scope
+ * auto-switch) so the `active.projectId === activeProjectId` invariant holds.
+ * This is NOT a cancel path — the previously-active turn keeps running in the
+ * background (D6 auto-park).
+ */
setActiveSession(id: string): void {
const session = this.sessions.get(id);
if (!session) return;
if (this.activeSessionId === id) return;
+ if (session.projectId !== this.activeProjectId) {
+ this.parkActiveScope();
+ this.activeProjectId = session.projectId;
+ }
+ // Surfacing a session (history open / tab click) re-attaches it to its
+ // scope's tab strip if a prior project re-entry had detached it.
+ this.detachedFromTabIds.delete(id);
this.activeSessionId = id;
+ this.lastActiveByScope.set(session.projectId, id);
session.clearNeedsAttention();
this.notify();
}
@@ -1169,7 +2238,10 @@ export class AgentSessionManager {
*/
async replaceSessionInPlace(oldId: string, backendId?: BackendId): Promise {
const oldIdx = Array.from(this.sessions.keys()).indexOf(oldId);
- const created = await this.createSession(backendId);
+ // The replacement inherits the REPLACED session's scope, not the active
+ // scope (they can differ if the old tab wasn't the active one).
+ const replacedProjectId = this.sessions.get(oldId)?.projectId ?? this.activeProjectId;
+ const created = await this.createSession(backendId, replacedProjectId);
if (oldIdx >= 0) {
this.moveMapEntry(this.sessions, created.internalId, oldIdx);
this.moveMapEntry(this.chatUIStates, created.internalId, oldIdx);
@@ -1269,8 +2341,9 @@ export class AgentSessionManager {
/**
* Subscribe to lifecycle changes (session created/closed/active changed/
- * label changed, backend exit, isStarting/lastError flips). Returns an
- * unsubscribe function. Listeners must not throw.
+ * label changed, backend exit, isStarting/lastError flips). Also fires when a
+ * session enters or leaves `running` (so the recent-list spinner can follow).
+ * Returns an unsubscribe function. Listeners must not throw.
*/
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
@@ -1300,6 +2373,11 @@ export class AgentSessionManager {
if (this.disposed) return;
this.disposed = true;
this.settingsUnsub();
+ // Unsubscribe SYNCHRONOUSLY up front: the teardown below awaits, and a
+ // project-record change landing in that window must not re-enter the handler
+ // on a half-disposed manager.
+ this.projectRecordsUnsubscriber?.();
+ this.projectRecordsUnsubscriber = undefined;
logInfo(
`[AgentMode] shutdown (pool size=${this.sessions.size}, backends=${this.backends.size})`
);
@@ -1329,7 +2407,13 @@ export class AgentSessionManager {
);
this.sessions.clear();
this.chatUIStates.clear();
+ this.landingCaptureSignatures.clear();
this.activeSessionId = null;
+ this.activeProjectId = GLOBAL_SCOPE;
+ this.lastActiveByScope.clear();
+ this.detachedFromTabIds.clear();
+ this.contextDirtySignatures.clear();
+ this.firstSessionPromiseByScope.clear();
const allBackends = Array.from(this.backends.values());
await Promise.allSettled(
@@ -1383,13 +2467,38 @@ export class AgentSessionManager {
const previousActiveId = this.activeSessionId;
const loaded = await this.opts.persistenceManager.loadFile(file);
-
- let session: AgentSession | null = null;
- if (loaded.sessionId) {
- session = await this.tryResumeSessionFromHistory(loaded.backendId, loaded.sessionId);
+ // `loaded.projectId` is authoritative (GLOBAL_SCOPE for legacy chats with no
+ // frontmatter). Read scope FIRST so the resumed session gets the right cwd;
+ // an orphaned project (deleted) shows a Notice and aborts rather than
+ // downgrading to the vault root (no recovery menu yet).
+ const projectId = loaded.projectId;
+ if (projectId !== GLOBAL_SCOPE && !getCachedProjectRecordById(projectId)) {
+ new Notice("This chat belongs to a project that no longer exists.");
+ throw new OrphanedProjectError(projectId);
}
- if (!session) {
- session = await this.createSession(loaded.backendId);
+ // Point the active scope at the chat's scope before constructing its
+ // session (which then activates within that scope). No restore/spawn here —
+ // we create the specific saved session next. Snapshot the scope this call
+ // actually replaces right here, AFTER the awaits above — capturing earlier
+ // would let a scope switch raced in during `loadFile` make rollback restore
+ // a stale scope.
+ const previousActiveProjectId = this.activeProjectId;
+ this.setActiveScope(projectId);
+
+ // Resume or create the saved session. Either await can reject (e.g. a
+ // missing backend binary fails to spawn); on failure, undo the scope switch
+ // above so a failed open doesn't leave the active scope ahead of the active
+ // session. The throw points are all before a session activates, so no
+ // already-active session in the target scope can be wrongly rolled back.
+ let session: AgentSession;
+ try {
+ const resumed = loaded.sessionId
+ ? await this.tryResumeSessionFromHistory(loaded.backendId, loaded.sessionId, projectId)
+ : null;
+ session = resumed ?? (await this.createSession(loaded.backendId, projectId));
+ } catch (err) {
+ this.rollbackHistoryLoadScope(projectId, previousActiveProjectId);
+ throw err;
}
session.loadDisplayMessages(loaded.messages);
@@ -1400,6 +2509,7 @@ export class AgentSessionManager {
// merged history ranks this chat correctly after a reopen.
void this.opts.sessionIndex?.touch(loaded.backendId, loaded.sessionId);
}
+ this.lastActiveByScope.set(projectId, session.internalId);
this.absorbIntoEmptyActiveTab(session, previousActiveId);
this.notify();
return session;
@@ -1457,9 +2567,36 @@ export class AgentSessionManager {
// Captured before the resumed session becomes active so we can replace an
// empty landing tab in place rather than spawning a new one.
const previousActiveId = this.activeSessionId;
- const session = await this.tryResumeSessionFromHistory(backendId, sessionId);
- if (!session) {
- throw new Error(`Could not resume session ${sessionId} from the ${backendId} session store.`);
+ const index = this.opts.sessionIndex;
+ const entry = index ? await index.getEntry(backendId, sessionId) : null;
+ // Scope from the index entry (write-through / sweep attribution); absent ≙
+ // global. Mirrors loadSessionFromHistory's frontmatter handling: orphan
+ // guard first (never downgrade a project chat to the vault root), then
+ // point the active scope at the chat's scope before constructing it.
+ const projectId: ProjectScopeId = entry?.projectId ?? GLOBAL_SCOPE;
+ if (projectId !== GLOBAL_SCOPE && !getCachedProjectRecordById(projectId)) {
+ new Notice("This chat belongs to a project that no longer exists.");
+ throw new OrphanedProjectError(projectId);
+ }
+ // Snapshot the scope this call actually replaces right here, AFTER the
+ // `getEntry` await — capturing earlier would let a scope switch raced in
+ // during the await make rollback restore a stale scope.
+ const previousActiveProjectId = this.activeProjectId;
+ this.setActiveScope(projectId);
+ // Unlike markdown history there is no fresh-session fallback — a failed
+ // resume rejects, so undo the scope switch above before propagating it.
+ let session: AgentSession;
+ try {
+ const resumed = await this.tryResumeSessionFromHistory(backendId, sessionId, projectId);
+ if (!resumed) {
+ throw new Error(
+ `Could not resume session ${sessionId} from the ${backendId} session store.`
+ );
+ }
+ session = resumed;
+ } catch (err) {
+ this.rollbackHistoryLoadScope(projectId, previousActiveProjectId);
+ throw err;
}
// Rebuild the visible transcript for backends that resume without
// replaying it (Claude SDK reads its on-disk session jsonl). ACP backends
@@ -1467,17 +2604,14 @@ export class AgentSessionManager {
// session already has its messages. Best-effort: an empty result leaves
// the resumed-but-blank session as-is rather than failing the open.
await this.hydrateResumedTranscript(session, backendId, sessionId);
- const index = this.opts.sessionIndex;
- if (index) {
- const entry = await index.getEntry(backendId, sessionId);
- // Reapply with the recorded source: a user rename stays sticky, but an
- // agent/derived title is agent-sourced so a resumed opencode/codex
- // session can still refresh its title from later agent updates.
- if (entry?.title) {
- session.restoreLabel(entry.title, entry.titleSource === "user" ? "user" : "agent");
- }
- await index.touch(backendId, sessionId);
+ // Reapply with the recorded source: a user rename stays sticky, but an
+ // agent/derived title is agent-sourced so a resumed opencode/codex
+ // session can still refresh its title from later agent updates.
+ if (entry?.title) {
+ session.restoreLabel(entry.title, entry.titleSource === "user" ? "user" : "agent");
}
+ if (index) await index.touch(backendId, sessionId);
+ this.lastActiveByScope.set(projectId, session.internalId);
this.absorbIntoEmptyActiveTab(session, previousActiveId);
this.notify();
return session;
@@ -1497,12 +2631,15 @@ export class AgentSessionManager {
const proc = this.backends.get(backendId);
if (!proc?.readPersistedTranscript) return;
if (session.store.getDisplayMessages().length > 0) return;
- const adapter = this.app.vault.adapter;
- if (!(adapter instanceof FileSystemAdapter)) return;
try {
+ // The store is keyed by the cwd the session RAN in (Claude encodes the
+ // transcript path from it), so a project chat must hydrate from its
+ // project folder — the vault root would look in the wrong directory.
+ // Resolved inside the try: hydration is best-effort, and an orphaned
+ // scope just skips it like any other store miss.
const transcript = await proc.readPersistedTranscript({
sessionId,
- cwd: adapter.getBasePath(),
+ cwd: this.resolveSessionCwd(session.projectId),
});
if (transcript.length > 0) session.loadDisplayMessages(transcript);
} catch (e) {
@@ -1519,11 +2656,24 @@ export class AgentSessionManager {
*/
private async tryResumeSessionFromHistory(
backendId: BackendId,
- sessionId: SessionId
+ sessionId: SessionId,
+ projectId: ProjectScopeId
): Promise {
- const adapter = this.app.vault.adapter;
- if (!(adapter instanceof FileSystemAdapter)) return null;
- const vaultBasePath = adapter.getBasePath();
+ // Same AGENTS.md mirror ensure as `createSession` — resume rehydrates an existing
+ // session, but its cwd still derives from the project record, so a cwd-instruction
+ // backend needs the mirror materialized before cwd is read. Never throws; skipped for
+ // GLOBAL_SCOPE and for claude.
+ if (projectId !== GLOBAL_SCOPE && CWD_INSTRUCTION_BACKENDS.has(backendId)) {
+ const record = getCachedProjectRecordById(projectId);
+ if (record) await ensureAgentsMirror(this.app, record);
+ }
+ const cwd = this.resolveSessionCwd(projectId);
+ // Kick off materialization in parallel (publishes the blocking load state up
+ // front), overlapping with `ensureBackend`. Unlike a fresh create, a resumed
+ // session can't appear before the backend rehydrates it, so we await the
+ // roots just before `loadSession`. Never rejects → resume is never blocked.
+ const contextReady =
+ projectId === GLOBAL_SCOPE ? undefined : this.beginContextMaterialization(projectId, cwd);
const descriptor = this.resolveDescriptor(backendId);
this.pendingCreates++;
@@ -1549,9 +2699,29 @@ export class AgentSessionManager {
const mcpServers = resolveMcpServers(backend, getSettings().agentMode?.mcpServers);
+ // Await the roots now (materialize has been running alongside ensureBackend).
+ // GLOBAL has no contextReady → undefined, identical to a context-free resume.
+ // The inline `` block is for fresh first prompts only, so a
+ // resumed session uses just the searchable roots here.
+ const additionalDirectories = contextReady
+ ? (await contextReady).additionalDirectories
+ : undefined;
+ // The await above is a new suspension point — re-check disposal before
+ // touching the (possibly tearing-down) backend, mirroring the fresh-create
+ // guard in AgentSession.initialize. Same cleanup as the path's other returns.
+ if (this.disposed) {
+ this.finishPendingCreate();
+ return null;
+ }
let resumeResult: { sessionId: SessionId; state: BackendState } | null = null;
try {
- resumeResult = await backend.loadSession({ sessionId, cwd: vaultBasePath, mcpServers });
+ resumeResult = await backend.loadSession({
+ sessionId,
+ cwd,
+ mcpServers,
+ projectId,
+ additionalDirectories,
+ });
} catch (err) {
if (!(err instanceof MethodUnsupportedError)) {
logWarn(`[AgentMode] loadSession failed for ${sessionId}`, err);
@@ -1564,8 +2734,10 @@ export class AgentSessionManager {
try {
resumeResult = await backend.resumeSession({
sessionId,
- cwd: vaultBasePath,
+ cwd,
mcpServers,
+ projectId,
+ additionalDirectories,
});
} catch (err) {
if (err instanceof MethodUnsupportedError) {
@@ -1590,8 +2762,9 @@ export class AgentSessionManager {
backendSessionId: resumeResult.sessionId,
internalId: uuidv4(),
backendId,
+ projectId,
initialState: resumeResult.state,
- cwd: vaultBasePath,
+ cwd,
getDescriptor: () => this.opts.resolveDescriptor(backendId),
runFanoutTurn: (input) => this.runFanoutTurn(input),
getDisplayName: (id) => this.resolveDescriptor(id).displayName,
@@ -1599,7 +2772,10 @@ export class AgentSessionManager {
});
this.sessions.set(session.internalId, session);
this.chatUIStates.set(session.internalId, new AgentChatUIState(session));
+ this.detachedFromTabIds.delete(session.internalId);
this.activeSessionId = session.internalId;
+ this.activeProjectId = projectId;
+ this.lastActiveByScope.set(projectId, session.internalId);
this.attachAutoSave(session);
this.attachModelCacheSync(session);
this.attachAttentionTracking(session);
@@ -1688,6 +2864,10 @@ export class AgentSessionManager {
titleSource,
createdAtMs: messages[0]?.timestamp?.epoch ?? now,
lastAccessedAtMs: now,
+ // Scope the native entry so project views can list it (markdown chats
+ // carry the scope in frontmatter; native-only chats have only this).
+ // GLOBAL_SCOPE maps to the field's "absent" encoding.
+ projectId: session.projectId === GLOBAL_SCOPE ? undefined : session.projectId,
});
}
@@ -1746,6 +2926,9 @@ export class AgentSessionManager {
label,
existingPath: state.path,
sessionId,
+ // GLOBAL_SCOPE writes no frontmatter (byte-identical to legacy chats);
+ // a real project id binds the chat to that scope on disk.
+ projectId: session.projectId,
});
if (result) {
state.path = result.path;
@@ -1827,12 +3010,21 @@ export class AgentSessionManager {
onMessagesChanged: () => {},
onStatusChanged: (next) => {
const wasRunning = prev === "running";
+ const isRunning = next === "running";
prev = next;
void this.flushDeferredBackendRestartIfReady(session.backendId);
- if (!wasRunning) return;
- if (!ATTENTION_TRIGGER_STATUSES.has(next)) return;
- if (this.activeSessionId === session.internalId) return;
- session.markNeedsAttention();
+ // Existing attention marking — unchanged semantics: a backgrounded
+ // session that leaves `running` for a status that demands the user's eye.
+ if (
+ wasRunning &&
+ ATTENTION_TRIGGER_STATUSES.has(next) &&
+ this.activeSessionId !== session.internalId
+ ) {
+ session.markNeedsAttention();
+ }
+ // Re-render recent-list rows when this session's running membership
+ // flips, so the row's spinner appears/disappears in step.
+ if (wasRunning !== isRunning) this.notify();
},
});
this.getSessionState(session.internalId).attentionUnsub = unsubscribe;
@@ -1868,6 +3060,29 @@ export class AgentSessionManager {
// Lets a backend with its own permission gate (Claude SDK) hard-deny write/exec
// tools for read-only fan-out sub-sessions — see `permissionBridge`.
proc.setReadOnlySessionPredicate?.((sessionId) => this.isReadOnlyFanoutSession(sessionId));
+ // Inject the project-instruction resolver. Wiring here (the single
+ // warm-adopt + fresh choke point) covers both backend bring-up paths.
+ // Backends that discover instructions from cwd (codex/opencode) omit the
+ // setter and this is a no-op.
+ proc.setProjectProfileProvider?.((projectId) => this.getProjectProfile(projectId));
+ }
+
+ /**
+ * Map a scope id to the minimal {@link ProjectProfile} a backend needs to
+ * inject project instructions. Returns `undefined` for {@link GLOBAL_SCOPE}
+ * or an unknown project — keeping the `projects/` lookup here so
+ * `backends/` never imports the projects layer.
+ */
+ private getProjectProfile(projectId: ProjectScopeId): ProjectProfile | undefined {
+ if (projectId === GLOBAL_SCOPE) return undefined;
+ const record = getCachedProjectRecordById(projectId);
+ if (!record) return undefined;
+ return {
+ id: record.project.id,
+ // Layer the built-in project policy ahead of the user's own instruction body, so Claude's
+ // `` carries the same composed body codex/opencode get via the mirror.
+ systemPrompt: getComposedProjectInstructions(record),
+ };
}
private async ensureBackend(
@@ -1934,12 +3149,33 @@ export class AgentSessionManager {
this.detachAutoSave(s.internalId);
this.sessions.delete(s.internalId);
this.chatUIStates.delete(s.internalId);
+ this.landingCaptureSignatures.delete(s.internalId);
+ this.detachedFromTabIds.delete(s.internalId);
+ // Drop the dead session from its scope's MRU so a later enter can't
+ // resurrect a dead id.
+ if (this.lastActiveByScope.get(s.projectId) === s.internalId) {
+ this.lastActiveByScope.delete(s.projectId);
+ }
s.cancel().catch(() => {});
s.dispose().catch(() => {});
}
if (this.activeSessionId && !this.sessions.has(this.activeSessionId)) {
- const remaining = Array.from(this.sessions.keys());
- this.activeSessionId = remaining[0] ?? null;
+ // Repoint at a STRIP-VISIBLE survivor only. A session detached on a prior
+ // project re-entry was deliberately parked to history; crash recovery must
+ // not silently resurrect it into the active tab. If none is visible, leave
+ // active null — the crash pill shows and the router (which bails on
+ // lastError) won't auto-respawn behind the user.
+ let next: AgentSession | undefined;
+ for (const s of this.sessions.values()) {
+ if (!this.detachedFromTabIds.has(s.internalId)) {
+ next = s;
+ break;
+ }
+ }
+ this.activeSessionId = next?.internalId ?? null;
+ // Crash repointing can cross scopes; keep `active.projectId ===
+ // activeProjectId` rather than leaving a stale scope behind.
+ if (next) this.activeProjectId = next.projectId;
}
// Surface the crash so the empty-state pill shows it and the
// router's auto-spawn effect (which bails on lastError) doesn't
@@ -1997,10 +3233,12 @@ export class AgentSessionManager {
// affected sessions are still closed; the tab falls back to its
// needs-setup state. `restartBackendNow` is otherwise only reached for an
// installed backend, so this is a no-op for the normal restart path.
+ const replacedSession = affected.find((s) => s.internalId === this.activeSessionId);
const shouldCreateReplacement =
- this.isBackendInstalled(backendId) &&
- affected.length > 0 &&
- affected.some((s) => s.internalId === this.activeSessionId);
+ this.isBackendInstalled(backendId) && affected.length > 0 && replacedSession !== undefined;
+ // The replacement must inherit the REPLACED session's scope, not the
+ // current active scope — captured before the close loop repoints `active`.
+ const replacementProjectId = replacedSession?.projectId ?? this.activeProjectId;
for (const session of affected) {
await this.closeSession(session.internalId);
}
@@ -2023,7 +3261,9 @@ export class AgentSessionManager {
this.registerPreload(backendId, probe);
if (shouldCreateReplacement && !this.disposed) {
await probe;
- await this.createSession(backendId);
+ // The replacement inherits the REPLACED session's scope (captured
+ // above as `replacementProjectId`), not the current active scope.
+ await this.createSession(backendId, replacementProjectId);
}
}
this.notify();
diff --git a/src/agentMode/session/agentChatYaml.ts b/src/agentMode/session/agentChatYaml.ts
new file mode 100644
index 00000000..ff481fd9
--- /dev/null
+++ b/src/agentMode/session/agentChatYaml.ts
@@ -0,0 +1,53 @@
+/**
+ * Tiny YAML-frontmatter helpers shared by `AgentChatPersistenceManager`. Kept
+ * in their own module so the manager stays focused on persistence flow and
+ * under the file-size budget. All functions are pure and side-effect free.
+ */
+
+/**
+ * Escape a string for a safe YAML double-quoted value. Strips control chars
+ * (including newlines) up front — a stray `\n` in the user's topic would
+ * otherwise terminate the line and corrupt the rest of the frontmatter.
+ */
+export function escapeYamlString(str: string): string {
+ return (
+ str
+ // eslint-disable-next-line no-control-regex
+ .replace(/[\x00-\x1F\x7F]/g, " ")
+ .replace(/\\/g, "\\\\")
+ .replace(/"/g, '\\"')
+ );
+}
+
+/**
+ * Inverse of {@link escapeYamlString} for the values our hand-rolled
+ * frontmatter parser extracts. Only handles the two escapes we emit (`\\` and
+ * `\"`).
+ */
+export function unescapeYamlString(str: string): string {
+ let out = "";
+ for (let i = 0; i < str.length; i++) {
+ const c = str[i];
+ if (c === "\\" && i + 1 < str.length) {
+ const next = str[i + 1];
+ if (next === "\\" || next === '"') {
+ out += next;
+ i++;
+ continue;
+ }
+ }
+ out += c;
+ }
+ return out;
+}
+
+/**
+ * Coerce a raw frontmatter `projectId` to a trimmed string, or `undefined`
+ * when absent/blank. Obsidian's YAML parser turns an unquoted numeric id into a
+ * number, so accept that too.
+ */
+export function coerceProjectId(value: unknown): string | undefined {
+ if (typeof value === "string") return value.trim() || undefined;
+ if (typeof value === "number") return String(value);
+ return undefined;
+}
diff --git a/src/agentMode/session/scope.ts b/src/agentMode/session/scope.ts
new file mode 100644
index 00000000..5e60afbd
--- /dev/null
+++ b/src/agentMode/session/scope.ts
@@ -0,0 +1,28 @@
+/**
+ * Shared scope identity for Agent Mode sessions and persisted chats.
+ *
+ * A session/chat is either bound to a real project (its `projectId`) or to the
+ * implicit global workspace. The sentinel below is the authoritative key for
+ * that global bucket so every layer (session manager, persistence, history)
+ * agrees on one value instead of juggling `undefined` vs empty string.
+ */
+/**
+ * DESIGN NOTE — `__global__` sentinel vs a hand-edited project id (deferred).
+ *
+ * (a) Trigger: a project's `AGENTS.md`/`project.md` frontmatter `id` is manually
+ * edited (or corrupted by a sync conflict) to the literal string
+ * `"__global__"`. Normal project ids are UUIDs minted by AddProjectModal, so
+ * this is only reachable by hand-editing frontmatter, not through the UI.
+ * (b) Impact: that project collides with the global sentinel and is treated as
+ * global — its session cwd falls back to the vault root, `enterProject`
+ * validation is skipped, and saved chats write no `projectId` frontmatter.
+ * (c) Why it's not fixed here: the correct guard is a reserved-id check at
+ * project creation/parse time (`createProject` / `parseProjectConfigFile`).
+ * Enforcing it from this module would force `projects/` to reverse-import a
+ * `session/` sentinel — a layering inversion not worth paying. Reserved-id
+ * validation belongs in the project domain, where this guard should live.
+ */
+export const GLOBAL_SCOPE = "__global__" as const;
+
+/** A real project id, or {@link GLOBAL_SCOPE} for the global workspace. */
+export type ProjectScopeId = string;
diff --git a/src/agentMode/session/sessionScope.ts b/src/agentMode/session/sessionScope.ts
new file mode 100644
index 00000000..c69da92c
--- /dev/null
+++ b/src/agentMode/session/sessionScope.ts
@@ -0,0 +1,79 @@
+import { dirname, join } from "node:path";
+import { getCachedProjectRecordById, getCachedProjectRecords } from "@/projects/state";
+import { GLOBAL_SCOPE, type ProjectScopeId } from "@/agentMode/session/scope";
+
+/**
+ * Raised when a project-scoped session points at a project whose registry
+ * record is gone (the project folder/config was deleted out from under it).
+ * Callers turn this into an "orphaned" experience (Notice + blocked send)
+ * instead of silently re-homing the session to the vault root.
+ */
+export class OrphanedProjectError extends Error {
+ constructor(readonly projectId: ProjectScopeId) {
+ super(`Project "${projectId}" is no longer available.`);
+ this.name = "OrphanedProjectError";
+ }
+}
+
+/**
+ * Absolute working directory for a session bound to `projectId`.
+ * - {@link GLOBAL_SCOPE} → the vault root.
+ * - a real project → that project's folder (the parent dir of its config file).
+ *
+ * A missing record is treated as orphaned and throws: never downgrade to the
+ * vault root, which would silently widen a "project" session to the whole vault.
+ */
+export function resolveScopeCwd(vaultBasePath: string, projectId: ProjectScopeId): string {
+ if (projectId === GLOBAL_SCOPE) return vaultBasePath;
+ const record = getCachedProjectRecordById(projectId);
+ if (!record) throw new OrphanedProjectError(projectId);
+ // Reason: project folders live at `dirname(config)`; a config sitting at the
+ // vault root yields ".", which join() collapses back to the vault root.
+ return join(vaultBasePath, dirname(record.filePath));
+}
+
+/** Whether `projectId` still resolves to a live scope (global is always live). */
+export function isLiveScope(projectId: ProjectScopeId): boolean {
+ return projectId === GLOBAL_SCOPE || getCachedProjectRecordById(projectId) !== undefined;
+}
+
+/**
+ * Reverse of {@link resolveScopeCwd}: attribute a working directory reported by
+ * a backend's native `listSessions` to the project whose folder it is. Lets the
+ * session-index sweep scope sessions started OUTSIDE the plugin (e.g. a CLI run
+ * inside a materialized project folder). Returns undefined for the vault root
+ * and for any path that matches no known project folder — exact folder match
+ * only, deliberately not "inside a project folder", mirroring the sweep's
+ * exact-cwd vault filter.
+ */
+export function resolveProjectIdForCwd(vaultBasePath: string, cwd: string): string | undefined {
+ const norm = (p: string) => p.replace(/[/\\]+$/, "");
+ const target = norm(cwd);
+ if (target === norm(vaultBasePath)) return undefined;
+ for (const record of getCachedProjectRecords()) {
+ if (target === norm(join(vaultBasePath, dirname(record.filePath)))) {
+ return record.project.id;
+ }
+ }
+ return undefined;
+}
+
+/**
+ * Pick the session that becomes active after closing one inside a scope, never
+ * leaving the scope: most-recently-used (if still alive) → the slot the closed
+ * session occupied (right neighbour, clamped to the new last) → `null` when the
+ * scope is now empty.
+ *
+ * @param scopeIdsAfterClose session ids remaining in the scope, in tab order.
+ * @param closedIdx index the closed session held within the scope before removal.
+ * @param mruCandidate the scope's recorded MRU id, if any.
+ */
+export function pickScopeNeighbor(
+ scopeIdsAfterClose: readonly string[],
+ closedIdx: number,
+ mruCandidate: string | undefined
+): string | null {
+ if (mruCandidate && scopeIdsAfterClose.includes(mruCandidate)) return mruCandidate;
+ if (scopeIdsAfterClose.length === 0) return null;
+ return scopeIdsAfterClose[Math.min(closedIdx, scopeIdsAfterClose.length - 1)];
+}
diff --git a/src/agentMode/session/types.ts b/src/agentMode/session/types.ts
index dd972b9f..434f23db 100644
--- a/src/agentMode/session/types.ts
+++ b/src/agentMode/session/types.ts
@@ -2,6 +2,7 @@ import type React from "react";
import type { FormattedDateTime, MessageContext } from "@/types/message";
// `import type` keeps the cycle with `fanoutTypes` compile-time only.
import type { FanoutTurn } from "@/agentMode/session/fanout/fanoutTypes";
+import type { ProjectScopeId } from "./scope";
export type {
BackendAuth,
@@ -392,6 +393,16 @@ export interface AgentPlanEntry {
status: "pending" | "in_progress" | "completed";
}
+/**
+ * One entry of the session's live execution todo list — the consumer-facing
+ * projection of `plan` updates (priority dropped; no surface renders it).
+ * Read via `AgentChatBackend.getCurrentTodoList()`.
+ */
+export interface AgentTodoListEntry {
+ content: string;
+ status: AgentPlanEntry["status"];
+}
+
/**
* Initial / updated state for an in-flight tool call. Mirrors ACP `ToolCall`
* (initial form) — emitted by backends in the `tool_call` `SessionUpdate`.
@@ -588,11 +599,41 @@ export type McpServerSpec =
headers: Array<{ name: string; value: string }>;
};
+// ---- Project scope ------------------------------------------------------
+
+/**
+ * Minimal per-session view of a project's instruction payload. Deliberately
+ * decoupled from `aiParams.ProjectConfig` (which still carries legacy CAG
+ * fields like `projectModelKey`): a backend that injects project instructions
+ * needs only the scope id and the already-parsed, frontmatter-stripped system
+ * prompt. The manager maps `ProjectConfig → ProjectProfile` so `backends/`
+ * never imports the `projects/` layer.
+ */
+export interface ProjectProfile {
+ id: string;
+ systemPrompt: string;
+}
+
// ---- Session-creation I/O shapes ---------------------------------------
export interface OpenSessionInput {
cwd: string;
mcpServers: McpServerSpec[];
+ /**
+ * Scope this session belongs to ({@link ProjectScopeId}); captured like
+ * `cwd` so the backend can resolve the owning project's instructions.
+ * Absent / `GLOBAL_SCOPE` means the implicit global workspace.
+ */
+ projectId?: ProjectScopeId;
+ /**
+ * Absolute paths to widen the agent's searchable roots beyond `cwd` (the
+ * project's materialized context directories). Forwarded on every
+ * session-lifecycle request (new / resume / load) — resume and load
+ * re-establish the complete root list — and only by backends that report
+ * {@link BackendProcess.supportsAdditionalDirectories}. Absent / empty means
+ * no extra roots.
+ */
+ additionalDirectories?: string[];
}
export interface OpenSessionOutput {
@@ -604,6 +645,10 @@ export interface ResumeSessionInput {
sessionId: SessionId;
cwd: string;
mcpServers: McpServerSpec[];
+ /** Scope this session belongs to. See {@link OpenSessionInput.projectId}. */
+ projectId?: ProjectScopeId;
+ /** Extra searchable roots. See {@link OpenSessionInput.additionalDirectories}. */
+ additionalDirectories?: string[];
}
export type ResumeSessionOutput = OpenSessionOutput;
@@ -612,6 +657,10 @@ export interface LoadSessionInput {
sessionId: SessionId;
cwd: string;
mcpServers: McpServerSpec[];
+ /** Scope this session belongs to. See {@link OpenSessionInput.projectId}. */
+ projectId?: ProjectScopeId;
+ /** Extra searchable roots. See {@link OpenSessionInput.additionalDirectories}. */
+ additionalDirectories?: string[];
}
export type LoadSessionOutput = OpenSessionOutput;
@@ -685,6 +734,16 @@ export interface BackendProcess {
* sessions; ACP backends governed by the shared prompter omit it.
*/
setReadOnlySessionPredicate?(fn: (sessionId: SessionId) => boolean): void;
+ /**
+ * Optional: register the resolver a backend calls to look up a session's
+ * project instructions by scope id. Mirrors the prompter setters; the
+ * manager supplies a resolver that maps the cached project record to a
+ * minimal {@link ProjectProfile}. Returns `undefined` for `GLOBAL_SCOPE`
+ * or an unknown project. Only backends that inject project instructions
+ * implement it (Claude SDK in PR2b-1); codex/opencode discover AGENTS.md
+ * from the session cwd and omit it.
+ */
+ setProjectProfileProvider?(fn: (projectId: ProjectScopeId) => ProjectProfile | undefined): void;
registerSessionHandler(sessionId: SessionId, handler: SessionUpdateHandler): () => void;
newSession(params: OpenSessionInput): Promise;
prompt(params: PromptInput): Promise;
@@ -730,6 +789,16 @@ export interface BackendProcess {
* Claude SDK adapter accepts http/sse natively.
*/
supportsMcpTransport(transport: "http" | "sse"): boolean;
+ /**
+ * Whether the backend honors {@link OpenSessionInput.additionalDirectories}
+ * (widening the agent's searchable roots on every session-lifecycle request:
+ * new / resume / load). Optional like the
+ * other incrementally-added capability hooks (`setProjectProfileProvider`):
+ * the manager always forwards `additionalDirectories` and each backend gates
+ * internally on this before passing them to its wire/SDK. Backends that
+ * cannot widen roots omit it or return `false`.
+ */
+ supportsAdditionalDirectories?(): boolean;
shutdown(): Promise;
}
diff --git a/src/agentMode/ui/AgentChatInput.test.tsx b/src/agentMode/ui/AgentChatInput.test.tsx
index d67f3cab..6771fb2c 100644
--- a/src/agentMode/ui/AgentChatInput.test.tsx
+++ b/src/agentMode/ui/AgentChatInput.test.tsx
@@ -1,12 +1,19 @@
import { EMPTY_AGENT_MENTION_BRANDS } from "@/components/chat-components/hooks/useAtMentionCategories";
-import { render } from "@testing-library/react";
+import { AgentChatInput } from "@/agentMode/ui/AgentChatInput";
+import type { AgentChatBackend } from "@/agentMode/session/AgentChatBackend";
+import type { AgentInputDraftControls } from "@/agentMode/ui/hooks/useAgentInputDrafts";
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import type { App } from "obsidian";
import React from "react";
+// Mock factory names must match the real `use*` exports, so the no-hook `use`
+// prefix is expected on the mocked hooks below.
+/* eslint-disable @eslint-react/hooks-extra/no-unnecessary-use-prefix */
+
// Entitlement gate — flipped per test.
const mockUseCanUseMultiAgent = jest.fn();
const mockNavigateToPlusPage = jest.fn();
jest.mock("@/plusUtils", () => ({
- // eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-prefix -- mocks the real `useCanUseMultiAgent` hook; the name must match the export
useCanUseMultiAgent: () => mockUseCanUseMultiAgent(),
navigateToPlusPage: (...args: unknown[]) => mockNavigateToPlusPage(...args),
}));
@@ -20,74 +27,91 @@ jest.mock("@/agentMode/ui/mentionedAgents", () => ({
listInstalledAgentBrands: () => FAKE_BRANDS,
}));
-// Capture the brands handed to the editor without rendering the heavy
-// Lexical-backed composer.
+// One ChatInput mock serves both suites: it captures the brands handed to the
+// editor (agent-mention gate) AND renders a clickable send button that routes
+// through `handleSendMessage` — the same entry the real Lexical editor's Enter
+// key hits (send-flow regression tests).
let capturedAgentBrands: ReadonlyArray | undefined;
jest.mock("@/components/chat-components/ChatInput", () => ({
__esModule: true,
- default: (props: { agentBrands?: ReadonlyArray }) => {
+ default: (props: { agentBrands?: ReadonlyArray; handleSendMessage?: () => void }) => {
capturedAgentBrands = props.agentBrands;
- return null;
+ return (
+
+ );
},
}));
-// Trim incidental module-level dependencies the composer pulls in.
jest.mock("@/components/chat-components/hooks/useActiveWebTabState", () => ({
- // eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-prefix -- mocks the real `useActiveWebTabState` hook; the name must match the export
useActiveWebTabState: () => ({ activeWebTabForMentions: undefined }),
}));
jest.mock("@/aiParams", () => ({
clearSelectedTextContexts: jest.fn(),
removeSelectedTextContext: jest.fn(),
- // eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-prefix -- mocks the real `useSelectedTextContexts` hook; the name must match the export
useSelectedTextContexts: () => [[], jest.fn()],
}));
jest.mock("@/settings/model", () => ({
- // eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-prefix -- mocks the real `useSettingsValue` hook; the name must match the export
useSettingsValue: () => ({}),
}));
+/* eslint-enable @eslint-react/hooks-extra/no-unnecessary-use-prefix */
+
jest.mock("@/commands/customCommandManager", () => ({
CustomCommandManager: { getInstance: () => ({ recordUsage: jest.fn() }) },
}));
jest.mock("@/commands/state", () => ({ getCachedCustomCommands: () => [] }));
+jest.mock("@/agentMode/session/expandCustomCommandPrefix", () => ({
+ expandCustomCommandPrefix: async (text: string) => ({ text }),
+}));
+jest.mock("@/services/webViewerService/activeWebTabSnapshot", () => ({
+ buildWebTabsWithActiveSnapshot: () => [],
+}));
-import { AgentChatInput } from "@/agentMode/ui/AgentChatInput";
+const makeApp = (): App => ({ workspace: { getActiveFile: () => null } }) as unknown as App;
-function renderComposer() {
- const draft = {
- input: "",
- images: [],
- contextNotes: [],
- includeActiveNote: false,
- includeActiveWebTab: false,
- loading: false,
- queue: [],
- setInput: jest.fn(),
- setContextNotes: jest.fn(),
- setSelectedImages: jest.fn(),
- addImages: jest.fn(),
- setIncludeActiveNote: jest.fn(),
- setIncludeActiveWebTab: jest.fn(),
- setLoading: jest.fn(),
- setQueue: jest.fn(),
- resetCompose: jest.fn(),
- };
- const app = { workspace: { getActiveFile: () => null } };
- const props = {
- backend: { sendMessage: jest.fn(), cancel: jest.fn() },
- sessionId: "s1",
- draft,
- app,
- mainAgentId: null,
- updateUserMessageHistory: jest.fn(),
- isStarting: false,
- hasPendingPlanPermission: false,
- modelPickerOverride: null,
- modePickerOverride: null,
- onCycleMode: jest.fn(),
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- } as any;
- render();
+const makeDraft = (overrides: Partial = {}): AgentInputDraftControls => ({
+ input: "hello",
+ images: [],
+ contextNotes: [],
+ includeActiveNote: false,
+ includeActiveWebTab: false,
+ loading: false,
+ queue: [],
+ setInput: jest.fn(),
+ setContextNotes: jest.fn(),
+ setSelectedImages: jest.fn(),
+ addImages: jest.fn(),
+ setIncludeActiveNote: jest.fn(),
+ setIncludeActiveWebTab: jest.fn(),
+ setLoading: jest.fn(),
+ setQueue: jest.fn(),
+ migrateDraft: jest.fn(),
+ resetCompose: jest.fn(),
+ ...overrides,
+});
+
+function renderInput(
+ backend: AgentChatBackend,
+ draft: AgentInputDraftControls,
+ extraProps: Partial> = {}
+) {
+ return render(
+
+ );
}
describe("AgentChatInput agent-mention gate", () => {
@@ -98,13 +122,76 @@ describe("AgentChatInput agent-mention gate", () => {
it("passes the real installed-agent list when entitled", () => {
mockUseCanUseMultiAgent.mockReturnValue(true);
- renderComposer();
+ renderInput(
+ { sendMessage: jest.fn(), cancel: jest.fn() } as unknown as AgentChatBackend,
+ makeDraft()
+ );
expect(capturedAgentBrands).toBe(FAKE_BRANDS);
});
it("passes the frozen empty list (not a fresh []) when not entitled", () => {
mockUseCanUseMultiAgent.mockReturnValue(false);
- renderComposer();
+ renderInput(
+ { sendMessage: jest.fn(), cancel: jest.fn() } as unknown as AgentChatBackend,
+ makeDraft()
+ );
expect(capturedAgentBrands).toBe(EMPTY_AGENT_MENTION_BRANDS);
});
});
+
+describe("AgentChatInput turn-completion loading reset", () => {
+ it("regression: clears draft.loading when the turn resolves after the composer unmounted", async () => {
+ // First send from a landing: the user message lands, AgentHome flips
+ // landing→conversation, and the composer remounts mid-turn. The unmounting
+ // instance's runSend must still clear the shared draft's loading flag,
+ // or the Thinking spinner / stop button stick forever (#stuck-thinking).
+ let resolveTurn!: () => void;
+ const turn = new Promise((resolve) => {
+ resolveTurn = resolve;
+ });
+ const backend = {
+ sendMessage: jest.fn(() => ({ turn })),
+ cancel: jest.fn(),
+ } as unknown as AgentChatBackend;
+ const draft = makeDraft();
+
+ const { unmount } = renderInput(backend, draft);
+ fireEvent.click(screen.getByText("send"));
+
+ await waitFor(() => expect(draft.setLoading).toHaveBeenCalledWith(true));
+ expect(backend.sendMessage).toHaveBeenCalledTimes(1);
+
+ // The landing→conversation flip unmounts this composer instance while the
+ // turn is still in flight.
+ unmount();
+
+ await act(async () => {
+ resolveTurn();
+ await turn;
+ });
+
+ await waitFor(() => expect(draft.setLoading).toHaveBeenCalledWith(false));
+ });
+});
+
+describe("AgentChatInput hard-disable", () => {
+ it("drops a send when the composer is disabled (orphaned project)", async () => {
+ // The mocked ChatInput's send button routes through handleSendMessage — the
+ // same entry the real Lexical editor's Enter key hits. A hard-disabled
+ // composer only dims + blocks pointer events in the DOM, so this keyboard
+ // path must be gated in the handler or a turn leaks into a dead project.
+ const backend = {
+ sendMessage: jest.fn(() => ({ turn: Promise.resolve() })),
+ cancel: jest.fn(),
+ } as unknown as AgentChatBackend;
+ const draft = makeDraft();
+
+ renderInput(backend, draft, { disabled: true });
+ fireEvent.click(screen.getByText("send"));
+ await act(async () => {});
+
+ expect(backend.sendMessage).not.toHaveBeenCalled();
+ expect(draft.setLoading).not.toHaveBeenCalledWith(true);
+ expect(draft.resetCompose).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/agentMode/ui/AgentChatInput.tsx b/src/agentMode/ui/AgentChatInput.tsx
index ecd61526..ef82ff3d 100644
--- a/src/agentMode/ui/AgentChatInput.tsx
+++ b/src/agentMode/ui/AgentChatInput.tsx
@@ -1,4 +1,5 @@
import type { AgentChatBackend } from "@/agentMode/session/AgentChatBackend";
+import { GLOBAL_SCOPE } from "@/agentMode/session/scope";
import { expandCustomCommandPrefix } from "@/agentMode/session/expandCustomCommandPrefix";
import { resolveActiveNoteToken } from "@/agentMode/session/resolveActiveNoteToken";
import type { PromptContent } from "@/agentMode/session/types";
@@ -66,6 +67,26 @@ interface AgentChatInputProps {
modelPickerOverride: ChatInputProps["modelPickerOverride"];
modePickerOverride: ChatInputProps["modePickerOverride"];
onCycleMode: () => void;
+ /**
+ * Active scope ({@link GLOBAL_SCOPE} or a project id). Gates the
+ * context-load hold below to real projects; `GLOBAL_SCOPE` never holds.
+ */
+ activeProjectId?: string;
+ /**
+ * The active project's context is still materializing (read by the parent from
+ * `agentProjectContextLoadAtom[projectId].blocking`). Send stays clickable but
+ * **queues** instead of firing, then auto-flushes when the load clears —
+ * queue-and-hold, never a hard-disabled button.
+ */
+ contextLoadBlocking?: boolean;
+ /**
+ * Hard-disable the whole composer (pointer-events + dim), e.g. the active
+ * project was deleted out from under the user. Distinct from
+ * {@link contextLoadBlocking}, which only defers sends.
+ */
+ disabled?: boolean;
+ /** Agent project-context status icon, rendered in the composer's badge row. */
+ contextStatusIndicator?: React.ReactNode;
}
// Stable no-op handlers for ChatInput props that don't apply to Agent Mode
@@ -160,9 +181,18 @@ export const AgentChatInput = memo(function AgentChatInput({
modelPickerOverride,
modePickerOverride,
onCycleMode,
+ activeProjectId,
+ contextLoadBlocking = false,
+ disabled = false,
+ contextStatusIndicator,
}: AgentChatInputProps) {
const eventTarget = useContext(EventTargetContext);
const settings = useSettingsValue();
+
+ // Hold sends only while a *real* project's context is materializing. Global
+ // scope never holds, so the global landing's send path is byte-identical.
+ const holdForContext =
+ contextLoadBlocking && !!activeProjectId && activeProjectId !== GLOBAL_SCOPE;
const [selectedTextContexts] = useSelectedTextContexts();
// SSoT for the Active Web Tab; `activeWebTabForMentions` matches the send
// snapshot (preserved only when focusing the chat panel). Drives the
@@ -170,7 +200,6 @@ export const AgentChatInput = memo(function AgentChatInput({
// webTabs at send time below.
const { activeWebTabForMentions } = useActiveWebTabState();
- const isMountedRef = useRef(false);
const previousSessionIdRef = useRef(sessionId);
// The `@agent` typeahead group + pills are paid-only. Reactive so a settings
@@ -216,13 +245,6 @@ export const AgentChatInput = memo(function AgentChatInput({
resetCompose,
} = draft;
- useEffect(() => {
- isMountedRef.current = true;
- return () => {
- isMountedRef.current = false;
- };
- }, []);
-
// Clear cross-session ephemeral state on a session switch: the global
// selected-text atom and the mentioned-agent ref (neither is reset by the
// editor remount), so a selection or `@agent` pill can't ride into the next session.
@@ -242,7 +264,7 @@ export const AgentChatInput = memo(function AgentChatInput({
// Stop = user is bailing on the current turn; don't auto-flush queued
// follow-ups they composed while the agent was running.
setQueuedMessages([]);
- if (isMountedRef.current) setLoading(false);
+ setLoading(false);
}, [backend, setLoading, setQueuedMessages]);
const runSend = useCallback(
@@ -261,7 +283,14 @@ export const AgentChatInput = memo(function AgentChatInput({
logError("Error sending agent message:", error);
new Notice("Failed to send message. Please try again.");
} finally {
- if (isMountedRef.current) setLoading(false);
+ // No mounted guard here: this composer remounts on the
+ // landing→conversation flip (AgentHome renders it at different tree
+ // positions), which happens DURING the first turn of every session —
+ // the unmounting instance must still clear the in-flight flag.
+ // `setLoading` writes AgentHome's per-session draft store (not local
+ // state), so calling it after unmount is safe, and the store itself
+ // drops updates for sessions that are no longer live.
+ setLoading(false);
}
},
[backend, setLoading, updateUserMessageHistory]
@@ -269,6 +298,10 @@ export const AgentChatInput = memo(function AgentChatInput({
const handleSendMessage = useCallback(
async (webTabs?: WebTabContext[]) => {
+ // A hard-disabled composer (e.g. an orphaned project) must not send. The
+ // wrapper only blocks pointer events + dims, so a focused editor could
+ // otherwise submit a turn via the keyboard; bail before any prep work.
+ if (disabled) return;
const text = inputMessage.trim();
if (!text) return;
const rawInput = inputMessage;
@@ -351,7 +384,10 @@ export const AgentChatInput = memo(function AgentChatInput({
// again, point them here.
clearSelectedTextContexts();
- if (loading || isStarting) {
+ // Queue-and-hold: while a turn is in flight, starting, or the project's
+ // context is still materializing, park the message instead of sending.
+ // The flush effect below drains it once all three clear.
+ if (loading || isStarting || holdForContext) {
setQueuedMessages((q) => [...q, item]);
return;
}
@@ -368,6 +404,8 @@ export const AgentChatInput = memo(function AgentChatInput({
selectedTextContexts,
loading,
isStarting,
+ holdForContext,
+ disabled,
resetCompose,
runSend,
setQueuedMessages,
@@ -397,11 +435,14 @@ export const AgentChatInput = memo(function AgentChatInput({
// deferred to PR2; PR1 keeps execution in the foreground composer. If a
// future review flags this again, point them at this note.
useEffect(() => {
- if (loading || isStarting || queuedMessages.length === 0) return;
+ // `disabled` guards the same hard-disable as the send path: a project
+ // orphaned while messages are queued must not drain its queue into a
+ // disabled composer.
+ if (disabled || loading || isStarting || holdForContext || queuedMessages.length === 0) return;
const combined = combineQueuedMessages(queuedMessages);
setQueuedMessages([]);
void runSend(combined);
- }, [loading, isStarting, queuedMessages, runSend, setQueuedMessages]);
+ }, [disabled, loading, isStarting, holdForContext, queuedMessages, runSend, setQueuedMessages]);
const handleRemoveQueuedMessage = useCallback(
(id: string) => {
@@ -429,8 +470,10 @@ export const AgentChatInput = memo(function AgentChatInput({
)}
{!canUseMultiAgent && }
{/* Key by session so ChatInput remounts on a tab/session switch. The
per-session draft store (input/images/contextNotes/include flags)
@@ -472,6 +515,7 @@ export const AgentChatInput = memo(function AgentChatInput({
onMentionedAgentsChange={handleMentionedAgentsChange}
showProgressCard={NOOP}
showIndexingCard={NOOP}
+ contextStatusIndicator={contextStatusIndicator}
/>
>
diff --git a/src/agentMode/ui/AgentContextSection.test.tsx b/src/agentMode/ui/AgentContextSection.test.tsx
new file mode 100644
index 00000000..f2e1adda
--- /dev/null
+++ b/src/agentMode/ui/AgentContextSection.test.tsx
@@ -0,0 +1,119 @@
+// Mock the Manage modal so its transitive Obsidian-subclass imports
+// (FuzzySuggestModal, absent from the obsidian mock) don't crash module load.
+jest.mock("@/components/modals/project/context-manage-modal", () => ({
+ ContextManageModal: jest.fn().mockImplementation(() => ({ open: jest.fn() })),
+}));
+
+import AgentContextSection, { buildContextSummary } from "@/agentMode/ui/AgentContextSection";
+import type { ProjectConfig } from "@/aiParams";
+import { updateCachedProjectRecords } from "@/projects/state";
+import { createPatternSettingsValue } from "@/search/searchUtils";
+import { render, screen } from "@testing-library/react";
+import { App } from "obsidian";
+import React from "react";
+
+beforeAll(() => {
+ // Obsidian exposes `activeDocument` as a global; jsdom doesn't. The reused
+ // ProjectContextBadgeList → TruncatedText tooltip reads it on render.
+ window.activeDocument = window.document;
+});
+
+function makeProject(overrides: Partial = {}): ProjectConfig {
+ return {
+ id: "p1",
+ name: "Halcyon Scope",
+ systemPrompt: "",
+ projectModelKey: "",
+ modelConfigs: {},
+ contextSource: {},
+ created: 0,
+ UsageTimestamps: 0,
+ ...overrides,
+ };
+}
+
+describe("buildContextSummary", () => {
+ it("returns an empty summary for an undefined project", () => {
+ const summary = buildContextSummary(undefined);
+ expect(summary.isEmpty).toBe(true);
+ expect(summary.totalItems).toBe(0);
+ expect(summary.urls).toBe(0);
+ });
+
+ it("returns an empty summary when the project has no context sources", () => {
+ expect(buildContextSummary(makeProject()).isEmpty).toBe(true);
+ });
+
+ it("counts inclusion badges by type + URLs", () => {
+ const inclusions = createPatternSettingsValue({
+ folderPatterns: ["notes/research"],
+ notePatterns: ["[[Intro]]"],
+ tagPatterns: ["#ml"],
+ });
+ const project = makeProject({
+ contextSource: {
+ inclusions,
+ webUrls: "https://arxiv.org/abs/2403",
+ youtubeUrls: "https://youtu.be/xyz",
+ },
+ });
+
+ const summary = buildContextSummary(project);
+ expect(summary.isEmpty).toBe(false);
+ expect(summary.totalItems).toBe(5);
+ // Counted with the same parsers the reused badge list renders with: a note
+ // pattern surfaces as a "file", plus the folder, the tag, and 2 URLs.
+ expect(summary.files).toBe(1);
+ expect(summary.folders).toBe(1);
+ expect(summary.tags).toBe(1);
+ expect(summary.urls).toBe(2);
+ });
+});
+
+describe("AgentContextSection", () => {
+ const app = {} as App;
+
+ it("renders nothing for an unknown (orphaned) project", () => {
+ updateCachedProjectRecords([]);
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders the drop hint + Manage with no header when context is empty", () => {
+ updateCachedProjectRecords([
+ { project: makeProject(), filePath: "Halcyon Scope/project.md", folderName: "Halcyon Scope" },
+ ]);
+
+ render();
+
+ // The body is headerless (the placement — standalone or tab — supplies the
+ // header); it leads with the drop target and pins Manage in the footer. The
+ // empty state shows the drop hint both centered and in the persistent footer.
+ expect(screen.getAllByText("Drag files / folders here").length).toBeGreaterThan(0);
+ expect(screen.getByRole("button", { name: /Manage/ })).toBeTruthy();
+ expect(screen.queryByLabelText(/context/i)).toBeNull();
+ });
+
+ it("shows the badges + the combined drop box directly when populated", () => {
+ const inclusions = createPatternSettingsValue({
+ folderPatterns: ["notes/research"],
+ notePatterns: ["[[Intro]]"],
+ });
+ updateCachedProjectRecords([
+ {
+ project: makeProject({ contextSource: { inclusions } }),
+ filePath: "Halcyon Scope/project.md",
+ folderName: "Halcyon Scope",
+ },
+ ]);
+
+ render();
+
+ // No collapse step anymore — the reused ProjectContextBadgeList renders the
+ // raw inclusion patterns immediately.
+ expect(screen.getByText(/notes\/research/)).toBeTruthy();
+ expect(screen.getByText(/Intro/)).toBeTruthy();
+ // The chips box doubles as the drop target — its hint row is persistent.
+ expect(screen.getByText("Drag files / folders here")).toBeTruthy();
+ });
+});
diff --git a/src/agentMode/ui/AgentContextSection.tsx b/src/agentMode/ui/AgentContextSection.tsx
new file mode 100644
index 00000000..a346c79d
--- /dev/null
+++ b/src/agentMode/ui/AgentContextSection.tsx
@@ -0,0 +1,197 @@
+import { SHELF_BODY_FLOOR_CLASS } from "@/agentMode/ui/AgentHomeShelf";
+import type { ProjectConfig } from "@/aiParams";
+import { ContextManageModal } from "@/components/modals/project/context-manage-modal";
+import { buildBadgeItems } from "@/components/project/ProjectContextBadgeList";
+import { ProjectContextSourceEditor } from "@/components/project/ProjectContextSourceEditor";
+import { cn } from "@/lib/utils";
+import { logWarn } from "@/logger";
+import { ProjectFileManager } from "@/projects/ProjectFileManager";
+import { getCachedProjectRecordById, useProjects } from "@/projects/state";
+import { parseProjectUrls } from "@/utils/urlTagUtils";
+import { App } from "obsidian";
+import * as React from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { usePersistentContextDrop } from "./hooks/usePersistentContextDrop";
+
+type ContextSource = NonNullable;
+
+interface AgentContextSectionProps {
+ app: App;
+ /** Project whose context (inclusions/URLs) this section summarizes + edits. */
+ projectId: string;
+ /** Portal target for the editor's +URL popover — the AgentHome root, so it
+ * resolves to the correct document in popout windows. */
+ popoverContainer?: HTMLElement | null;
+}
+
+interface ContextSummary {
+ totalItems: number;
+ isEmpty: boolean;
+ /** Per-type counts, matching the badge list's categorizer + the URL parser. */
+ files: number;
+ folders: number;
+ tags: number;
+ extensions: number;
+ urls: number;
+}
+
+const EMPTY_SUMMARY: ContextSummary = Object.freeze({
+ totalItems: 0,
+ isEmpty: true,
+ files: 0,
+ folders: 0,
+ tags: 0,
+ extensions: 0,
+ urls: 0,
+});
+
+/**
+ * Summarize a project's context for the section header + breakdown line. Counts
+ * inclusions via {@link buildBadgeItems} and URLs via {@link parseProjectUrls} —
+ * the SAME parsers the reused editor renders with — so the counts match the
+ * expanded editor exactly. Pure / testable.
+ */
+export function buildContextSummary(project: ProjectConfig | undefined): ContextSummary {
+ if (!project) return EMPTY_SUMMARY;
+
+ const badgeItems = buildBadgeItems(project.contextSource?.inclusions);
+ const counts = { files: 0, folders: 0, tags: 0, extensions: 0 };
+ for (const item of badgeItems) {
+ if (item.type === "note") counts.files++;
+ else if (item.type === "folder") counts.folders++;
+ else if (item.type === "tag") counts.tags++;
+ else counts.extensions++;
+ }
+
+ const urls = parseProjectUrls(
+ project.contextSource?.webUrls ?? "",
+ project.contextSource?.youtubeUrls ?? ""
+ ).length;
+
+ const totalItems = badgeItems.length + urls;
+ if (totalItems === 0) return EMPTY_SUMMARY;
+
+ return { totalItems, isEmpty: false, ...counts, urls };
+}
+
+/**
+ * The project Context body on the agent landing — a single
+ * {@link ProjectContextSourceEditor} (mixed file + URL chips, +URL, Manage)
+ * wired to immediate persistence. Edits/drops apply to NEW chats.
+ *
+ * Persistence is optimistic + serialized to fix the prior drop-write race:
+ * `onChange` updates a local `draft` (so the chip vanishes instantly) and queues
+ * a patch; a single in-flight `updateProject` drains the queue, each write
+ * rebased on the freshest persisted config (`getCachedProjectRecordById`) so
+ * rapid successive removals compose instead of clobbering each other. External
+ * record changes are adopted into the draft only while no write is pending.
+ *
+ * The root keeps the drop ref, the `data-copilot-drop-zone` marker, AND the
+ * height floor on the SAME element: that marker tells the chat container's
+ * draft-attach handler (useChatFileDrop) to yield while a drag hovers this body,
+ * so a taller wrapper would leave a dead strip where drops fall through to the
+ * draft. The floor is the shelf panel's SHELF_BODY_FLOOR_CLASS (imported, not
+ * hand-copied) and also covers this section's standalone (no-shelf) rendering;
+ * `tw-grow` on the editor fills that floor.
+ */
+export default function AgentContextSection({
+ app,
+ projectId,
+ popoverContainer,
+}: AgentContextSectionProps) {
+ const projects = useProjects();
+ const project = useMemo(() => projects.find((p) => p.id === projectId), [projects, projectId]);
+ const externalContext = project?.contextSource;
+
+ const [draft, setDraft] = useState(externalContext);
+ const pendingRef = useRef | null>(null);
+ const writingRef = useRef(false);
+
+ // Adopt external changes only when nothing optimistic is queued/in flight, so a
+ // background record refresh can't resurrect a chip the user just removed. This
+ // is the intended prop→optimistic-state sync; the lint rule's blanket warning
+ // about set-state-in-effect doesn't fit a guarded reconciliation like this.
+ useEffect(() => {
+ // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
+ if (!pendingRef.current && !writingRef.current) setDraft(externalContext);
+ }, [externalContext]);
+
+ const flush = useCallback(() => {
+ if (writingRef.current) return;
+ const patch = pendingRef.current;
+ if (!patch) return;
+ pendingRef.current = null;
+ writingRef.current = true;
+ // Rebase each write on the freshest persisted config so queued patches
+ // compose rather than race on a stale closure-captured base.
+ const base = getCachedProjectRecordById(projectId)?.project;
+ if (!base) {
+ writingRef.current = false;
+ return;
+ }
+ ProjectFileManager.getInstance(app)
+ .updateProject(projectId, { ...base, contextSource: { ...base.contextSource, ...patch } })
+ .catch((err) => {
+ logWarn("[project-context] failed to save context changes", err);
+ // The optimistic draft assumed this write would land. With no further
+ // pending patch to reconcile it, resync the draft to the persisted config
+ // so the UI can't drift from disk (a removed chip reappearing, etc.).
+ if (!pendingRef.current) {
+ setDraft(getCachedProjectRecordById(projectId)?.project.contextSource);
+ }
+ })
+ .finally(() => {
+ writingRef.current = false;
+ if (pendingRef.current) flush();
+ });
+ }, [app, projectId]);
+
+ const handleChange = useCallback(
+ (patch: Partial) => {
+ setDraft((prev) => ({ ...(prev ?? {}), ...patch }));
+ pendingRef.current = { ...(pendingRef.current ?? {}), ...patch };
+ flush();
+ },
+ [flush]
+ );
+
+ const sectionRef = useRef(null);
+ const { isDragging } = usePersistentContextDrop({ app, projectId, dropRef: sectionRef });
+
+ const handleManage = useCallback(() => {
+ if (!project) return;
+ const modal = new ContextManageModal(
+ app,
+ (updated) => {
+ ProjectFileManager.getInstance(app)
+ .updateProject(projectId, updated)
+ .catch((err) => logWarn("[project-context] failed to save context changes", err));
+ },
+ project,
+ { enableLinks: true }
+ );
+ modal.open();
+ }, [app, project, projectId]);
+
+ // Orphaned/unknown project: nothing to edit.
+ if (!project) return null;
+
+ return (
+
+
+
+ );
+}
diff --git a/src/agentMode/ui/AgentContextStatusIcon.test.tsx b/src/agentMode/ui/AgentContextStatusIcon.test.tsx
new file mode 100644
index 00000000..377ec02a
--- /dev/null
+++ b/src/agentMode/ui/AgentContextStatusIcon.test.tsx
@@ -0,0 +1,252 @@
+import AgentContextStatusIcon, { buildStatusView } from "@/agentMode/ui/AgentContextStatusIcon";
+import {
+ agentProjectContextLoadAtom,
+ type AgentProjectContextLoadState,
+ type FailedItem,
+ type ProjectConfig,
+} from "@/aiParams";
+import { settingsStore } from "@/settings/model";
+import { act, render } from "@testing-library/react";
+import type { App } from "obsidian";
+import * as React from "react";
+
+// Radix Popover portals resolve Obsidian's `activeDocument` global at render;
+// jsdom doesn't define it. Point it at the test document.
+(window as unknown as { activeDocument: Document }).activeDocument = window.document;
+
+function entry(over: Partial = {}): AgentProjectContextLoadState {
+ return { phase: "done", blocking: false, ...over };
+}
+
+const webFailure: FailedItem = {
+ path: "https://a.com",
+ type: "web",
+ error: "boom",
+ usedStaleSnapshot: false,
+};
+const staleFailure: FailedItem = {
+ path: "https://b.com",
+ type: "web",
+ error: "network down",
+ usedStaleSnapshot: true,
+};
+
+describe("buildStatusView", () => {
+ it("rests on idle when there is no entry", () => {
+ const v = buildStatusView(undefined, true);
+ expect(v.kind).toBe("idle");
+ expect(v.headline).toBe("No context loaded");
+ });
+
+ it("rests on idle on the idle phase", () => {
+ expect(buildStatusView(entry({ phase: "idle" }), true).kind).toBe("idle");
+ });
+
+ it("rests on idle for a clean completion when the project has no configured context source", () => {
+ expect(buildStatusView(entry({ phase: "done" }), false).kind).toBe("idle");
+ });
+
+ it("is ready on a clean completion with a configured context source", () => {
+ const v = buildStatusView(entry({ phase: "done" }), true);
+ expect(v.kind).toBe("ready");
+ expect(v.headline).toBe("Context ready");
+ });
+
+ it("is working during a materialization phase", () => {
+ const v = buildStatusView(
+ entry({ phase: "prefetch", blocking: true, prefetch: { done: 2, total: 4 } }),
+ true
+ );
+ expect(v.kind).toBe("working");
+ expect(v.headline).toBe("Indexing context · 2/4");
+ });
+
+ it("is working when a retry is in flight even though the phase is still done", () => {
+ // A per-row retry sets `retryingSources` but leaves phase at "done" (and
+ // optimistically clears the failure) — the glyph must read working, not the
+ // green "ready" the bare phase check would give.
+ const v = buildStatusView(
+ entry({ phase: "done", retryingSources: [{ kind: "web", source: "https://a.com" }] }),
+ true
+ );
+ expect(v.kind).toBe("working");
+ });
+
+ it("is working when a source is processing even though the phase is still done", () => {
+ const v = buildStatusView(
+ entry({ phase: "done", processingSources: [{ kind: "web", source: "https://a.com" }] }),
+ true
+ );
+ expect(v.kind).toBe("working");
+ });
+
+ it("is failed from a persistent on-disk marker count when no entry exists yet", () => {
+ // A project whose failures live only as disk markers (Option D), with no run
+ // this session — the icon must read failed, not idle/ready.
+ const v = buildStatusView(undefined, true, 1);
+ expect(v.kind).toBe("failed");
+ expect(v.headline).toBe("1 source failed");
+ });
+
+ it("is failed from the persistent count on a clean-looking settled entry", () => {
+ const v = buildStatusView(entry({ phase: "done" }), true, 2);
+ expect(v.kind).toBe("failed");
+ expect(v.headline).toBe("2 sources failed");
+ });
+
+ it("prefers live missing failures over the persistent count", () => {
+ // A live missing failure is this run's truth; the disk count must not inflate
+ // the headline.
+ const v = buildStatusView(entry({ phase: "done", failedSources: [webFailure] }), true, 5);
+ expect(v.kind).toBe("failed");
+ expect(v.headline).toBe("1 source failed");
+ });
+
+ it("stays working over a persistent count while a retry is in flight", () => {
+ const v = buildStatusView(
+ entry({ phase: "done", retryingSources: [{ kind: "web", source: "https://a.com" }] }),
+ true,
+ 3
+ );
+ expect(v.kind).toBe("working");
+ });
+
+ it("is failed when a source is missing (no stale fallback)", () => {
+ const v = buildStatusView(entry({ phase: "done", failedSources: [webFailure] }), true);
+ expect(v.kind).toBe("failed");
+ expect(v.headline).toBe("1 source failed");
+ });
+
+ it("stays ready (green) when failures are stale-but-usable only", () => {
+ const v = buildStatusView(entry({ phase: "done", failedSources: [staleFailure] }), true);
+ expect(v.kind).toBe("ready");
+ // The stale failure is still surfaced in the popover list.
+ expect(v.failures).toHaveLength(1);
+ });
+
+ it("counts only missing sources in the failed headline", () => {
+ const v = buildStatusView(
+ entry({ phase: "done", failedSources: [webFailure, staleFailure] }),
+ true
+ );
+ expect(v.kind).toBe("failed");
+ expect(v.headline).toBe("1 source failed");
+ expect(v.failures).toHaveLength(2); // both shown in detail
+ });
+
+ it("shows real-count step rows when present", () => {
+ const v = buildStatusView(
+ entry({ phase: "done", resolved: 243, prefetch: { done: 4, total: 4 } }),
+ true
+ );
+ const labels = v.steps.map((s) => s.label);
+ expect(labels).toContain("Resolve files (243)");
+ expect(labels).toContain("Prefetch 4 URLs · 4/4");
+ // No fabricated "Apply 100-cap" row — only real-count steps are shown.
+ expect(labels).not.toContain("Apply 100-cap");
+ expect(v.steps.every((s) => s.status === "done")).toBe(true);
+ });
+
+ it("treats a synthetic whole-context failure as failed", () => {
+ const v = buildStatusView(
+ entry({ phase: "done", failedSources: [{ path: "Project context", type: "nonMd", error: "fs" }] }), // prettier-ignore
+ true
+ );
+ expect(v.kind).toBe("failed");
+ });
+});
+
+describe("AgentContextStatusIcon anti-flash", () => {
+ const PROJECT = "proj-1";
+ const noop = () => {};
+
+ function setEntry(e: AgentProjectContextLoadState | undefined) {
+ settingsStore.set(agentProjectContextLoadAtom, e ? { [PROJECT]: e } : {});
+ }
+
+ // app/project only matter once the popover opens (the conversion body mounts);
+ // these anti-flash tests assert the trigger glyph only, so minimal stubs suffice.
+ const APP = {} as unknown as App;
+ const PROJECT_CONFIG = { id: PROJECT } as unknown as ProjectConfig;
+
+ function renderIcon() {
+ return render(
+ false}
+ onRetryItem={() => Promise.resolve(false)}
+ onRefreshLanding={noop}
+ onEditContext={noop}
+ />
+ );
+ }
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ setEntry(undefined);
+ });
+ afterEach(() => {
+ // Real timers restored after RTL's auto-unmount (registered at import, so it
+ // runs after this inner hook) — the atom reset lives in beforeEach to avoid
+ // writing the store while a component from this test is still mounted.
+ jest.useRealTimers();
+ });
+
+ // The trigger button is always present now (the icon rests on the gray idle
+ // glyph), so anti-flash is asserted on the spinner glyph (`tw-animate-spin`),
+ // not on the button's presence.
+ const spinner = (c: HTMLElement) => c.querySelector(".tw-animate-spin");
+
+ it("shows the gray idle trigger with no entry (never hidden)", () => {
+ setEntry(undefined);
+ const { container } = renderIcon();
+ expect(container.querySelector('[aria-label="Project context status"]')).not.toBeNull();
+ expect(spinner(container)).toBeNull();
+ });
+
+ it("does not flash the working spinner if the run settles within 300ms (warm cache)", () => {
+ setEntry({ phase: "resolve", blocking: true });
+ const { container } = renderIcon();
+ // Within the reveal delay the trigger stays on the idle glyph — no spinner.
+ act(() => {
+ jest.advanceTimersByTime(250);
+ });
+ expect(spinner(container)).toBeNull();
+ // The run completes cleanly before the timer elapses → straight to ready.
+ act(() => {
+ setEntry({ phase: "done", blocking: false, failedSources: [] });
+ });
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+ expect(spinner(container)).toBeNull();
+ });
+
+ it("shows the working spinner once the run exceeds 300ms (cold)", () => {
+ setEntry({ phase: "prefetch", blocking: true, prefetch: { done: 0, total: 4 } });
+ const { container } = renderIcon();
+ // Masked as idle until the reveal delay elapses.
+ expect(spinner(container)).toBeNull();
+ act(() => {
+ jest.advanceTimersByTime(350);
+ });
+ expect(spinner(container)).not.toBeNull();
+ });
+
+ it("shows the working spinner immediately for a user retry (no anti-flash idle blink)", () => {
+ // A per-row retry keeps phase `done` and sets `retryingSources`; the user just
+ // clicked, so the spinner must show at once — never blink the neutral idle
+ // glyph first.
+ setEntry({
+ phase: "done",
+ blocking: false,
+ retryingSources: [{ kind: "web", source: "https://a.com" }],
+ });
+ const { container } = renderIcon();
+ expect(spinner(container)).not.toBeNull();
+ });
+});
diff --git a/src/agentMode/ui/AgentContextStatusIcon.tsx b/src/agentMode/ui/AgentContextStatusIcon.tsx
new file mode 100644
index 00000000..35ee2fe1
--- /dev/null
+++ b/src/agentMode/ui/AgentContextStatusIcon.tsx
@@ -0,0 +1,479 @@
+import { GLOBAL_SCOPE, type ProjectScopeId } from "@/agentMode/session/scope";
+import {
+ agentProjectContextLoadAtom,
+ type AgentProjectContextLoadState,
+ type FailedItem,
+ type ProjectConfig,
+} from "@/aiParams";
+import { AgentContextConversionModalContent } from "@/components/project/AgentContextConversionModalContent";
+import type { ProcessingItem } from "@/components/project/processingAdapter";
+import { useAgentPersistentFailureCount } from "@/components/project/useAgentPersistentFailureCount";
+import { useAgentProcessingItems } from "@/components/project/useAgentProcessingItems";
+import { Button } from "@/components/ui/button";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { settingsStore } from "@/settings/model";
+import { openAgentCachedItemPreview } from "@/utils/cacheFileOpener";
+import { useAtomValue } from "jotai";
+import { AlertCircle, CheckCircle, CircleDashed, Loader2 } from "lucide-react";
+import { App } from "obsidian";
+import * as React from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
+
+interface AgentContextStatusIconProps {
+ app: App;
+ /** Active workspace scope. {@link GLOBAL_SCOPE} never has a per-project entry. */
+ activeProjectId: ProjectScopeId;
+ /** The active project — drives the conversion popover's per-source list/preview. */
+ project: ProjectConfig;
+ /**
+ * Whether the project declares any context source (folder/note/tag/ext/URL/
+ * file). When false, a completed run with no failures rests on the neutral
+ * `idle` icon (nothing to report) rather than the green "ready" check. Computed
+ * by the parent from the project config.
+ */
+ hasConfiguredContextSource: boolean;
+ /**
+ * Whether the composer is in its landing layout (vertically centered, with
+ * content below it) rather than docked at the pane bottom. Drives the popover
+ * open direction: down from a centered composer (room below, would otherwise
+ * cover the hero above), up from a docked one. Radix still flips on collision
+ * if the chosen side lacks room.
+ */
+ landing: boolean;
+ /** Whole-run re-materialize (popover "Retry all/failed"): re-attempts failed
+ * sources (already-successful snapshots cheap-skip by fingerprint). Returns
+ * whether a run actually started (false for no-op scopes). */
+ onReindex: () => boolean;
+ /** Per-source retry (popover row "Retry") → `AgentSessionManager.rematerializeSource`.
+ * Resolves to whether the retry actually ran (false when deduped/skipped). */
+ onRetryItem: (item: ProcessingItem) => Promise;
+ /** Re-capture context into an empty landing session. Deferred until the popover
+ * closes so a retry's completion can't yank the popover out from under the user
+ * (the session swap remounts the composer, see {@link handleOpenChange}). The
+ * return value (if any) is not awaited — callers fire-and-forget. */
+ onRefreshLanding: () => void | Promise;
+ /** Open the project's context editor (popover "Edit context"). */
+ onEditContext: () => void;
+}
+
+/** Delay before the working spinner appears, so a warm (cache-hit) run that
+ * settles within the window keeps resting on the neutral `idle` icon instead of
+ * flashing the spinner (anti-flash). */
+const WORKING_REVEAL_MS = 300;
+
+/**
+ * The composer icon's resting/active glyphs — mirrors the legacy CAG chat-input
+ * status icon (`ChatContextMenu`): `idle` is its gray `CircleDashed`, the other
+ * three its spinner/check/alert. There is no "hidden": the icon is always shown
+ * for a project scope, resting on `idle` when there's nothing to report.
+ */
+type IconKind = "idle" | "working" | "ready" | "failed";
+
+interface StatusView {
+ kind: IconKind;
+ /** Collapsed headline shown in the popover header. */
+ headline: string;
+ /** Step rows (resolve/prefetch/parse), real counts only — never fabricated. */
+ steps: StatusStep[];
+ /** Per-source failures (already mapped to FailedItem by the manager). */
+ failures: FailedItem[];
+}
+
+interface StatusStep {
+ label: string;
+ status: "done" | "active" | "pending";
+}
+
+const PHASE_RANK: Record = {
+ idle: -1,
+ resolve: 0,
+ prefetch: 1,
+ parse: 2,
+ done: 3,
+};
+
+function stepStatus(
+ current: AgentProjectContextLoadState["phase"],
+ stepPhase: "resolve" | "prefetch" | "parse",
+ countComplete: boolean
+): StatusStep["status"] {
+ if (current === "done") return "done";
+ if (countComplete) return "done";
+ const rank = PHASE_RANK[current];
+ const stepRank = PHASE_RANK[stepPhase];
+ if (rank > stepRank) return "done";
+ if (rank === stepRank) return "active";
+ return "pending";
+}
+
+/**
+ * Derive the icon's view model from the raw load entry. Pure (no atom/timer), so
+ * it is unit-testable in isolation.
+ *
+ * - `idle`: no entry, idle phase, or a clean completion for a project that
+ * declares no context source (nothing worth reporting — the neutral resting
+ * state, mirroring the legacy CAG icon's `initial`).
+ * - `failed`: completed with at least one MISSING source (`usedStaleSnapshot`
+ * false). Stale-but-usable failures alone stay `ready` (green) — context is
+ * present, just old; the popover flags the staleness.
+ * - `ready`: completed, no missing sources, project has context configured.
+ * - `working`: a materialization phase is in flight.
+ */
+export function buildStatusView(
+ entry: AgentProjectContextLoadState | undefined,
+ hasConfiguredContextSource: boolean,
+ persistentMissingCount = 0
+): StatusView {
+ // Failures persist as on-disk markers (Option D), so a settled project can have
+ // failed sources the live atom no longer carries. `persistentMissingCount` (a
+ // disk read by the always-mounted icon — see useAgentPersistentFailureCount)
+ // surfaces them as failed even when no run is driving the atom. Only a NON-zero
+ // count matters; it never overrides an in-flight `working`.
+ const persistent = Math.max(0, persistentMissingCount);
+
+ if (!entry || entry.phase === "idle") {
+ // `persistent` is fed only here-via-`!entry`: a project that never ran a
+ // materialization this session (e.g. a reused landing) has NO atom entry, yet
+ // its prior failures live on disk. The `phase === "idle"` arm never receives a
+ // non-zero `persistent` because the hook gates its disk read to settled-done /
+ // no-entry (no producer writes a per-project `phase: "idle"` entry today); it
+ // shares this arm only to fall through to the same neutral idle glyph. The
+ // idle/persistent pairing is intentional — the live path is the `!entry` one.
+ if (persistent > 0) {
+ return { kind: "failed", headline: failedHeadline(persistent), steps: [], failures: [] };
+ }
+ return { kind: "idle", headline: "No context loaded", steps: [], failures: [] };
+ }
+
+ const failures = entry.failedSources ?? [];
+ const missing = failures.filter((f) => !f.usedStaleSnapshot);
+ const done = entry.phase === "done";
+ // A per-source / per-row retry runs while the phase stays `done` (it only sets
+ // `retryingSources`), and a full run carries `processingSources` mid-flight.
+ // Either means work is in flight, so the glyph must read "working" — not the
+ // green "ready" the bare phase check would give after an optimistic retry
+ // clears the failure.
+ const inFlight =
+ (entry.retryingSources?.length ?? 0) > 0 || (entry.processingSources?.length ?? 0) > 0;
+
+ // Build the real-count step rows — never fabricated, so a row appears only for
+ // work that actually has a count (resolved files, prefetched URLs, parsed files).
+ const steps: StatusStep[] = [];
+ if (entry.resolved !== undefined && entry.resolved > 0) {
+ steps.push({
+ label: `Resolve files (${entry.resolved})`,
+ status: stepStatus(entry.phase, "resolve", true),
+ });
+ }
+ if (entry.prefetch) {
+ const { done: d, total } = entry.prefetch;
+ steps.push({
+ label: `Prefetch ${total} ${total === 1 ? "URL" : "URLs"} · ${d}/${total}`,
+ status: stepStatus(entry.phase, "prefetch", d >= total),
+ });
+ }
+ if (entry.parsed) {
+ const { done: d, total } = entry.parsed;
+ steps.push({
+ label: `Parse ${total} ${total === 1 ? "file" : "files"} · ${d}/${total}`,
+ status: stepStatus(entry.phase, "parse", d >= total),
+ });
+ }
+
+ if (!done || inFlight) {
+ const totalCount = (entry.prefetch?.total ?? 0) + (entry.parsed?.total ?? 0);
+ const doneCount = (entry.prefetch?.done ?? 0) + (entry.parsed?.done ?? 0);
+ const headline =
+ !done && totalCount > 0
+ ? `Indexing context · ${doneCount}/${totalCount}`
+ : "Indexing context";
+ return { kind: "working", headline, steps, failures };
+ }
+
+ // Completed.
+ // Live missing failures take precedence; otherwise fall back to the persisted
+ // on-disk markers (the settled-state truth the popover already shows).
+ const missingCount = missing.length > 0 ? missing.length : persistent;
+ if (missingCount > 0) {
+ return { kind: "failed", headline: failedHeadline(missingCount), steps, failures };
+ }
+ // Clean (or stale-only) completion. Rest on the neutral `idle` icon when the
+ // project has no configured context source — there is nothing to call "ready".
+ if (!hasConfiguredContextSource && failures.length === 0) {
+ return { kind: "idle", headline: "No context loaded", steps: [], failures: [] };
+ }
+ return { kind: "ready", headline: "Context ready", steps, failures };
+}
+
+/** "N source(s) failed" headline for the failed glyph. */
+function failedHeadline(count: number): string {
+ return count === 1 ? "1 source failed" : `${count} sources failed`;
+}
+
+interface ResolvedStatus {
+ /** The real status — drives the popover's headline, steps, failures, actions. */
+ view: StatusView;
+ /**
+ * The glyph the trigger button shows. Equals `view.kind` except during the
+ * anti-flash window, where a `working` run is masked as `idle` so a warm run
+ * doesn't flash the spinner. The popover still reflects the real `view`, so
+ * opening it mid-mask shows the true "indexing" state (never a false Retry).
+ */
+ triggerKind: IconKind;
+}
+
+/**
+ * Reads the projectId-keyed load atom and applies the anti-flash delay: while a
+ * run is `working`, the TRIGGER GLYPH stays on the neutral `idle` icon until
+ * {@link WORKING_REVEAL_MS} has elapsed. A warm run that completes within the
+ * window therefore never flashes the spinner — the glyph goes straight from
+ * `idle` to `ready`/`failed`. Terminal states render immediately. Reset per
+ * project so a peer's timer never leaks in. The masking is glyph-only: `view`
+ * always carries the real status for the popover.
+ */
+function useStatusView(
+ app: App,
+ activeProjectId: ProjectScopeId,
+ project: ProjectConfig,
+ hasConfiguredContextSource: boolean
+): ResolvedStatus {
+ const states = useAtomValue(agentProjectContextLoadAtom, { store: settingsStore });
+ const entry = activeProjectId === GLOBAL_SCOPE ? undefined : states[activeProjectId];
+ // Persisted on-disk failures the live atom may not carry once a run has settled
+ // (Option D). Only consulted for the active project; gated to settled states
+ // inside the hook so a live run pays no extra disk I/O.
+ const persistentMissingCount = useAgentPersistentFailureCount(
+ app,
+ project,
+ entry,
+ activeProjectId !== GLOBAL_SCOPE && project.id === activeProjectId
+ );
+ const view = buildStatusView(entry, hasConfiguredContextSource, persistentMissingCount);
+
+ // Anti-flash gate, keyed on project + phase + a retry-episode flag so a fresh
+ // working phase restarts the delay and a project switch resets it. The flag
+ // captures ONLY a retry that runs while the phase stays `done` (the one working
+ // episode the phase can't mark); a live run's per-item `processingSources`
+ // churn must NOT enter the key, or the spinner would re-mask to idle at every
+ // source boundary. The reset is done during render (React's "adjust state from
+ // props" pattern) so only the timer's async reveal touches state from the
+ // effect — keeping the effect side-effect-only.
+ const [revealWorking, setRevealWorking] = useState(false);
+ const retryWhileDone =
+ entry?.phase === "done" &&
+ ((entry.retryingSources?.length ?? 0) > 0 || (entry.processingSources?.length ?? 0) > 0);
+ const delayKey = `${activeProjectId}\0${entry?.phase ?? "none"}\0${retryWhileDone ? "retry" : ""}`;
+ const prevKeyRef = useRef(delayKey);
+ if (prevKeyRef.current !== delayKey) {
+ prevKeyRef.current = delayKey;
+ if (revealWorking) setRevealWorking(false);
+ }
+
+ useEffect(() => {
+ if (view.kind !== "working") return;
+ const timer = window.setTimeout(() => setRevealWorking(true), WORKING_REVEAL_MS);
+ return () => window.clearTimeout(timer);
+ }, [view.kind, delayKey]);
+
+ // Anti-flash masks a `working` glyph as `idle` for the first WORKING_REVEAL_MS
+ // so a warm AUTO run that settles fast never flashes the spinner. A
+ // user-initiated retry (the `retryWhileDone` working episode) is exempt: the
+ // user just clicked, so show the spinner immediately rather than blink the
+ // neutral "no context" glyph first.
+ const masked = view.kind === "working" && !revealWorking && !retryWhileDone;
+ return { view, triggerKind: masked ? "idle" : view.kind };
+}
+
+/** Leading trigger icon for the current kind — mirrors the legacy CAG project
+ * status icon (CircleDashed / Loader2 / CheckCircle / AlertCircle, same size +
+ * theme colors). `idle` is the gray resting glyph. */
+function TriggerIcon({ kind }: { kind: IconKind }) {
+ if (kind === "failed") return ;
+ if (kind === "ready") return ;
+ if (kind === "idle") return ;
+ return ;
+}
+
+/**
+ * Composer status icon for a project's context materialization. Sits in the
+ * composer's top row (right of the context badges). The trigger glyph reflects
+ * idle/working/ready/failed (with an anti-flash delay); clicking opens the
+ * unified Content Conversion popover (design S) — the SAME surface the project
+ * header opens, so there's one place to see per-source status, retry a single
+ * source / the whole run, and jump to Edit context. Only mounted for a real
+ * (non-global, non-orphaned) project by the parent.
+ */
+export default function AgentContextStatusIcon({
+ app,
+ activeProjectId,
+ project,
+ hasConfiguredContextSource,
+ landing,
+ onReindex,
+ onRetryItem,
+ onRefreshLanding,
+ onEditContext,
+}: AgentContextStatusIconProps) {
+ const { triggerKind } = useStatusView(app, activeProjectId, project, hasConfiguredContextSource);
+ const [open, setOpen] = useState(false);
+
+ // A retry/reindex re-captures context by swapping the empty landing session,
+ // which remounts this whole composer subtree (ChatInput is `key={sessionId}`)
+ // and would close the popover. So we hold that refresh until the popover is
+ // dismissed. `openRef` lets a retry settling AFTER an early close still fire it.
+ const openRef = useRef(open);
+ // The scope a deferred refresh belongs to, captured when the retry/reindex was
+ // triggered (null = none pending). A retry can settle after the user switched
+ // projects on this same — never-remounted — composer icon; firing the landing
+ // refresh then would re-materialize the NEW project's session. So we only fire
+ // when the captured scope still matches the active one.
+ const pendingRefreshScopeRef = useRef(null);
+ const activeProjectIdRef = useRef(activeProjectId);
+ useEffect(() => {
+ activeProjectIdRef.current = activeProjectId;
+ }, [activeProjectId]);
+ // Always call the LATEST refresh. A slow retry can settle after the user closed
+ // the popover and started typing; the click-time closure would still see the
+ // old (empty) draft and wrongly replace the session, wiping that input. The
+ // current-render callback closes over the current draft, so its own empty-draft
+ // guard no-ops correctly.
+ const onRefreshLandingRef = useRef(onRefreshLanding);
+ useEffect(() => {
+ onRefreshLandingRef.current = onRefreshLanding;
+ }, [onRefreshLanding]);
+
+ // A per-source retry can settle after the user has switched scope and this icon
+ // unmounted; don't fire the landing refresh from a dead instance (it would act
+ // on whatever session is active by then).
+ const mountedRef = useRef(true);
+ useEffect(
+ () => () => {
+ mountedRef.current = false;
+ },
+ []
+ );
+
+ // Fire the landing refresh only if the still-active scope matches the one the
+ // refresh was queued for (see {@link pendingRefreshScopeRef}).
+ const fireRefreshIfSameScope = useCallback((scope: ProjectScopeId) => {
+ if (scope === activeProjectIdRef.current) void onRefreshLandingRef.current();
+ }, []);
+
+ const deferRefreshUntilClosed = useCallback(
+ (scope: ProjectScopeId) => {
+ if (openRef.current) pendingRefreshScopeRef.current = scope;
+ else fireRefreshIfSameScope(scope);
+ },
+ [fireRefreshIfSameScope]
+ );
+
+ const handleOpenChange = useCallback(
+ (nextOpen: boolean) => {
+ openRef.current = nextOpen;
+ setOpen(nextOpen);
+ if (!nextOpen && pendingRefreshScopeRef.current !== null) {
+ const scope = pendingRefreshScopeRef.current;
+ pendingRefreshScopeRef.current = null;
+ fireRefreshIfSameScope(scope);
+ }
+ },
+ [fireRefreshIfSameScope]
+ );
+
+ const handleRetryItem = useCallback(
+ async (item: ProcessingItem) => {
+ const scope = activeProjectIdRef.current;
+ if ((await onRetryItem(item)) && mountedRef.current) deferRefreshUntilClosed(scope);
+ },
+ [onRetryItem, deferRefreshUntilClosed]
+ );
+
+ const handleRetryAll = useCallback(() => {
+ const scope = activeProjectIdRef.current;
+ if (onReindex()) deferRefreshUntilClosed(scope);
+ }, [onReindex, deferRefreshUntilClosed]);
+
+ return (
+
+
+
+
+ {/* 368px + p-0: the conversion content owns its own width/padding and a
+ single inner scroll layer (no nested popover scroll). Open direction
+ follows the composer: down on the landing (centered), up when docked. */}
+
+ {/* Mounted only while open, so the per-source read-model (disk + atom)
+ runs on demand rather than for every composer render. */}
+ {open && (
+ {
+ handleOpenChange(false);
+ onEditContext();
+ }}
+ />
+ )}
+
+
+ );
+}
+
+/**
+ * The popover's body: reads the per-source conversion model and renders the
+ * shared three-state {@link AgentContextConversionModalContent}. Split out so
+ * `useAgentProcessingItems` only runs while the popover is open.
+ */
+function ConversionPopoverBody({
+ app,
+ project,
+ hasConfiguredContextSource,
+ onRetryItem,
+ onRetryAll,
+ onEditContext,
+}: {
+ app: App;
+ project: ProjectConfig;
+ hasConfiguredContextSource: boolean;
+ onRetryItem: (item: ProcessingItem) => void;
+ onRetryAll: () => void;
+ onEditContext: () => void;
+}) {
+ const { items, skippedMarkdownCount } = useAgentProcessingItems(
+ app,
+ project,
+ project.contextSource
+ );
+
+ const handleOpenCachedItem = (item: ProcessingItem) => {
+ // Off-vault snapshots are keyed by source identity, so no project folder is
+ // needed — the item's kind + id locate the snapshot.
+ void openAgentCachedItemPreview(app, item);
+ };
+
+ return (
+
+ );
+}
diff --git a/src/agentMode/ui/AgentHome.tsx b/src/agentMode/ui/AgentHome.tsx
index 5067a700..bfea1eef 100644
--- a/src/agentMode/ui/AgentHome.tsx
+++ b/src/agentMode/ui/AgentHome.tsx
@@ -1,8 +1,15 @@
import AgentChatMessages from "@/agentMode/ui/AgentChatMessages";
import { AgentChatControls } from "@/agentMode/ui/AgentChatControls";
import { AgentChatInput } from "@/agentMode/ui/AgentChatInput";
+import AgentContextSection, { buildContextSummary } from "@/agentMode/ui/AgentContextSection";
+import AgentContextStatusIcon from "@/agentMode/ui/AgentContextStatusIcon";
+import { AgentLandingStack } from "@/agentMode/ui/AgentLandingStack";
+import { CreateProjectPanel } from "@/agentMode/ui/CreateProjectPanel";
import { AgentModeStatus } from "@/agentMode/ui/AgentModeStatus";
+import { AgentProjectHeader } from "@/agentMode/ui/AgentProjectHeader";
+import { ProjectInfoPopover } from "@/agentMode/ui/ProjectInfoPopover";
import { AgentTabStrip } from "@/agentMode/ui/AgentTabStrip";
+import { AgentWelcomeCard } from "@/agentMode/ui/AgentWelcomeCard";
import { CopilotBrandIcon } from "@/agentMode/ui/CopilotBrandIcon";
import { AgentHomeShelf, type AgentHomeShelfSection } from "@/agentMode/ui/AgentHomeShelf";
import { GlobalRecentChatsSection } from "@/agentMode/ui/GlobalRecentChatsSection";
@@ -10,24 +17,36 @@ import { ProjectPickerList } from "@/agentMode/ui/ProjectPickerList";
import { useAgentChatRuntimeState } from "@/agentMode/ui/hooks/useAgentChatRuntimeState";
import { useAgentHistoryControls } from "@/agentMode/ui/hooks/useAgentHistoryControls";
import { useAgentInputDrafts } from "@/agentMode/ui/hooks/useAgentInputDrafts";
+import { useAttentionChatIds } from "@/agentMode/ui/hooks/useAttentionChatIds";
+import { useRunningChatIds } from "@/agentMode/ui/hooks/useRunningChatIds";
import { useChatInputAutoFocus } from "@/agentMode/ui/hooks/useChatInputAutoFocus";
+import { useRefreshEmptyLandingOnContextSourceChange } from "@/agentMode/ui/hooks/useRefreshEmptyLandingOnContextSourceChange";
import { useAgentModelPicker } from "@/agentMode/ui/useAgentModelPicker";
import { useAgentModePicker } from "@/agentMode/ui/useAgentModePicker";
import { useSessionBackendDescriptor } from "@/agentMode/ui/useBackendDescriptor";
import { pickRandomGreeting } from "@/agentMode/ui/landingGreetings";
import type { AgentChatBackend } from "@/agentMode/session/AgentChatBackend";
import type { AgentSessionManager } from "@/agentMode/session/AgentSessionManager";
+import { GLOBAL_SCOPE } from "@/agentMode/session/scope";
+import { agentProjectContextLoadAtom, type ProjectConfig } from "@/aiParams";
+import { makeNewProjectConfig } from "@/agentMode/ui/AgentProjectCreateForm";
+import { ContextManageModal } from "@/components/modals/project/context-manage-modal";
+import { TruncatedText } from "@/components/TruncatedText";
import { EVENT_NAMES } from "@/constants";
import { AppContext, ChatViewEventTarget, EventTargetContext } from "@/context";
import { ChatInputProvider, useChatInput } from "@/context/ChatInputContext";
import { useChatFileDrop } from "@/hooks/useChatFileDrop";
+import { cn } from "@/lib/utils";
import { logError } from "@/logger";
import type CopilotPlugin from "@/main";
-import { useProjects } from "@/projects/state";
-import { useSettingsValue } from "@/settings/model";
-import { Folder, MessageSquare } from "lucide-react";
+import { ProjectFileManager } from "@/projects/ProjectFileManager";
+import { getProjectLandingCaptureSignature } from "@/projects/projectContextSignature";
+import { getCachedProjectRecordById, useProjects } from "@/projects/state";
+import { getSettings, settingsStore, updateSetting, useSettingsValue } from "@/settings/model";
+import { useAtomValue } from "jotai";
+import { Files, Folder, MessageSquare } from "lucide-react";
import { Notice } from "obsidian";
-import React, { useCallback, useContext, useEffect, useMemo, useRef } from "react";
+import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
interface AgentHomeProps {
backend: AgentChatBackend;
@@ -44,12 +63,14 @@ interface AgentHomeProps {
* (the tab strip swaps `sessionId`/`backend` props), so input drafts live in
* `AgentChatInput` keyed by session rather than being reset by a `key` remount.
*
- * Derives a per-session view state: a session with no user-visible messages
- * shows the global landing (centered composer + read-only Projects / Recent
- * Chats); once it has messages it's the conversation. "Global" distinguishes
- * this no-project landing from the per-project landing PR2 adds. (The
- * no-session fallback is handled upstream in `AgentModeChat`.) The
- * `data-agent-landing` attribute marks the seam.
+ * Derives a per-session view state across three surfaces: a session with no
+ * user-visible messages is a landing — global (no project scope: top-anchored
+ * composer over Recent Chats / Projects) or per-project (the project's hero +
+ * scoped Recent Chats); once it has messages it's the conversation. The global
+ * and project landings share `AgentLandingStack`. The project header mounts over
+ * both the project landing and the in-project conversation. (The no-session
+ * fallback is handled upstream in `AgentModeChat`.) The `data-agent-landing`
+ * attribute ("global" | "project" | "conversation") marks the seam.
*/
const AgentHomeInternal: React.FC = ({
backend,
@@ -95,10 +116,16 @@ const AgentHomeInternal: React.FC = ({
isStarting,
hasPendingPlanPermission,
currentPlan,
+ currentTodoList,
pendingToolPermissions,
pendingAskUserQuestions,
} = useAgentChatRuntimeState(backend);
+ // Whole-surface root — the portal container for header-anchored overlays
+ // (the project-info popover), which live OUTSIDE chatContainerRef. Held in
+ // state (not a plain ref) so the popover re-renders once the node mounts and
+ // actually receives the container instead of the first-render `null`.
+ const [rootEl, setRootEl] = useState(null);
const chatContainerRef = useRef(null);
// External callers (CopilotPlugin.autosaveCurrentChat → CopilotAgentView.saveChat)
@@ -153,22 +180,154 @@ const AgentHomeInternal: React.FC = ({
descriptor.openInstallUI(plugin);
}, [descriptor, plugin]);
+ const projects = useProjects();
+
+ // Shared in-memory usage manager — the SAME instance `enterProject` touches. The
+ // picker blends it so entering a project reorders the landing list immediately,
+ // before the throttled disk persist catches up.
+ const projectUsageManager = useMemo(
+ () => ProjectFileManager.getInstance(app).getProjectUsageTimestampsManager(),
+ [app]
+ );
+
+ // Active scope drives the third (per-project) landing state and scopes the
+ // history list. `GLOBAL_SCOPE` is the implicit global workspace. Read fresh
+ // each render: the manager re-renders this tree on scope switch, and
+ // `useProjects()` re-renders it on project create/rename/delete — so the
+ // derived name and orphan flag stay live.
+ const activeProjectId = manager.getActiveProjectId();
+ const isProjectScope = activeProjectId !== GLOBAL_SCOPE;
+ const activeProject = isProjectScope ? projects.find((p) => p.id === activeProjectId) : undefined;
+ // The scope still points at a project whose record is gone (folder/`project.md`
+ // deleted while the user was inside it). Degrade rather than crash: the header
+ // keeps only the `‹` escape hatch, the composer hard-disables, and a one-time Notice fires.
+ const isOrphanedProject = isProjectScope && !activeProject;
+ const projectName = activeProject?.name ?? "";
+ // Latch the in-project header content so its exit collapse animates with the
+ // project it's leaving: `projectName`/`isOrphanedProject` flip to their global
+ // values the instant the scope changes, before the header's collapse finishes,
+ // which would otherwise flash an empty name mid-animation. The id rides along
+ // so the identity tile's color doesn't flash either. Writing the ref during
+ // render is the same derive-from-props pattern the context-load card uses.
+ // The global-scope initial id only ever lives in a collapsed, aria-hidden
+ // header; the first project entry latches a real project id.
+ const lastProjectHeaderRef = useRef({
+ id: activeProjectId,
+ name: projectName,
+ orphaned: isOrphanedProject,
+ });
+ if (isProjectScope) {
+ lastProjectHeaderRef.current = {
+ id: activeProjectId,
+ name: projectName,
+ orphaned: isOrphanedProject,
+ };
+ }
+ const headerProjectId = isProjectScope ? activeProjectId : lastProjectHeaderRef.current.id;
+ const headerName = isProjectScope ? projectName : lastProjectHeaderRef.current.name;
+ const headerOrphaned = isProjectScope ? isOrphanedProject : lastProjectHeaderRef.current.orphaned;
+
+ // Scope the history list to the active project (project id) or the flat
+ // all-chats view (`GLOBAL_SCOPE`). One wiring fixes both the landing's
+ // project Recent Chats shelf and the conversation-state History popover, which share
+ // this hook.
const {
chatHistoryItems,
+ chatHistorySettled,
loadChatHistory: handleLoadChatHistory,
loadChat: handleLoadChat,
updateChatTitle: handleUpdateChatTitle,
deleteChat: handleDeleteChat,
openSourceFile: handleOpenSourceFile,
- } = useAgentHistoryControls(manager, plugin);
+ } = useAgentHistoryControls(manager, plugin, activeProjectId);
- const projects = useProjects();
+ // Recent-list rows show a spinner for any chat whose backend turn is still
+ // running in the background (the session keeps streaming when its tab is
+ // parked), and a live done-dot the moment that turn finishes. Shared by both
+ // the global and per-project landing shelves.
+ const runningChatIds = useRunningChatIds(manager);
+ const attentionChatIds = useAttentionChatIds(manager);
- // Projects aren't shipped yet, so the Projects tab is disabled on the shelf
- // (greyed, "Coming soon" on hover) and its list never mounts. This no-op
- // stands in for ProjectPickerList's required handlers until selection lands;
- // it's unreachable while the tab is disabled.
- const handleProjectComingSoon = useCallback(() => {}, []);
+ // Blocking signal for the composer's send-gate: true while the active
+ // project's context is still materializing. Read-only here — the materializer
+ // owns writes to this atom.
+ const contextLoadStates = useAtomValue(agentProjectContextLoadAtom, { store: settingsStore });
+ const contextLoadBlocking =
+ isProjectScope && (contextLoadStates[activeProjectId]?.blocking ?? false);
+
+ // Leave the project scope, returning to the global workspace.
+ const handleExitProject = useCallback(() => {
+ manager.exitProject().catch((e) => {
+ logError("[AgentMode] exit project failed", e);
+ new Notice("Failed to leave project. Please try again.");
+ });
+ }, [manager]);
+
+ // A project was deleted via its `⋯` menu. If it was the active scope (deleted
+ // from the project header), drop back to the global workspace so we don't sit
+ // on a now-orphaned scope.
+ const handleProjectDeleted = useCallback(
+ (deletedId: string) => {
+ if (manager.getActiveProjectId() === deletedId) {
+ manager.exitProject().catch((e) => {
+ logError("[AgentMode] exit after delete failed", e);
+ });
+ }
+ },
+ [manager]
+ );
+
+ // Enter a project from the global shelf's Projects tab (P0 entry point).
+ const handleSelectProject = useCallback(
+ (project: ProjectConfig) => {
+ manager.enterProject(project.id).catch((e) => {
+ logError("[AgentMode] enter project failed", e);
+ new Notice("Failed to open project. Please try again.");
+ });
+ },
+ [manager]
+ );
+
+ // Name-only create: the modal assembles a full ProjectConfig; we persist it
+ // and drop straight into its landing. Rethrow on failure so the modal stays
+ // open instead of closing on a write that didn't land.
+ // Open the anchored name-only create panel next to whichever trigger was
+ // clicked (the "+ New project" row or the Welcome card button), so it appears
+ // beside the trigger instead of dead-center like the full edit modal.
+ const [createAnchor, setCreateAnchor] = useState(null);
+ const handleCreateProject = useCallback((anchorEl: HTMLElement) => {
+ setCreateAnchor(anchorEl);
+ }, []);
+
+ // Persist a name-only project then enter it. A reject (e.g. a duplicate name)
+ // bubbles to the panel's form, which shows a Notice and keeps the panel open.
+ const persistCreateProject = useCallback(
+ async ({ name }: { name: string }) => {
+ const project = makeNewProjectConfig(name);
+ try {
+ await ProjectFileManager.getInstance(app).createProject(project);
+ await manager.enterProject(project.id);
+ } catch (e) {
+ logError("[AgentMode] create project failed", e);
+ throw e;
+ }
+ setCreateAnchor(null);
+ },
+ [app, manager]
+ );
+
+ // Persist the global-landing Welcome card's dismissal. `updateSetting` replaces
+ // the whole `agentMode` object, so spread the freshest copy first.
+ const handleDismissWelcome = useCallback(() => {
+ updateSetting("agentMode", { ...getSettings().agentMode, welcomeDismissed: true });
+ }, []);
+
+ // Surface the orphaned-scope condition once when it appears (project deleted
+ // out from under the active session). The header + disabled composer let the
+ // user back out via the `‹` escape hatch.
+ useEffect(() => {
+ if (isOrphanedProject) new Notice("This project no longer exists.");
+ }, [isOrphanedProject]);
const modelPickerOverride = useAgentModelPicker(manager);
const modePickerOverride = useAgentModePicker(manager);
@@ -213,11 +372,109 @@ const AgentHomeInternal: React.FC = ({
containerRef: chatContainerRef,
});
- // Global landing vs conversation. The runtime subscription re-renders this
- // component as the stream updates, so reading the session's display-message
- // count here re-derives in step: once the user's first message lands, the
- // surface flips from the centered landing to the conversation layout.
- const isGlobalLanding = !manager.getActiveSession()?.hasUserVisibleMessages();
+ // Three surfaces, derived per render (the runtime subscription re-renders as
+ // the stream updates, so the message-count read re-derives in step): a session
+ // with no user-visible messages is a landing — global (no project scope) or
+ // per-project — and once its first message lands it becomes the conversation.
+ const isLanding = !manager.getActiveSession()?.hasUserVisibleMessages();
+ const isProjectLanding = isLanding && isProjectScope;
+
+ // A session captures its `` block + searchable roots once, at
+ // start (AgentSession.initialize awaiting `contextReady`). So after a Retry /
+ // Edit re-materializes, a still-empty landing session would otherwise keep the
+ // STALE inline block until a new chat. Replace it in place (the handleNewChat
+ // pattern) so its fresh `initialize()` re-captures the new context — createSession
+ // joins the just-started materialization (single-flight by project) on Retry, or
+ // re-materializes the updated config on Edit. Guarded on an EMPTY draft so a
+ // refresh never interrupts a draft already in progress. The replace mints a new
+ // session id and prunes the old draft, so to honor "never discard text the user
+ // has started typing" we also migrate any draft typed during the async startup
+ // window onto the new id (the pre-await check can't see those late keystrokes).
+ // Returns whether a swap actually happened (false = guarded no-op), so the
+ // context-source observer advances its baseline only on a real capture.
+ const refreshContextForEmptyLanding = useCallback(async (): Promise => {
+ const active = manager.getActiveSession();
+ if (!active || active.hasUserVisibleMessages()) return false;
+ const draftEmpty =
+ draft.input.trim() === "" &&
+ draft.images.length === 0 &&
+ draft.contextNotes.length === 0 &&
+ draft.queue.length === 0;
+ if (!draftEmpty) return false;
+ try {
+ const replacement = await manager.replaceSessionInPlace(active.internalId, active.backendId);
+ draft.migrateDraft(active.internalId, replacement.internalId);
+ return true;
+ } catch (e) {
+ logError("[AgentMode] refresh landing context failed", e);
+ return false;
+ }
+ }, [manager, draft]);
+
+ // Reactively refresh the empty landing when the active project's context
+ // sources change underneath it (drag-drop / inline edit / +URL / chip removal
+ // / Manage modal all funnel through the project store, which re-renders here).
+ // The status icon's Retry keeps its own direct refresh — it re-captures from
+ // the cache WITHOUT a project-store write, so this observer never sees it.
+ const draftIsEmpty =
+ draft.input.trim() === "" &&
+ draft.images.length === 0 &&
+ draft.contextNotes.length === 0 &&
+ draft.queue.length === 0;
+ // Fingerprint of what an empty landing session captures at creation: the
+ // materialization signature PLUS the project instructions. Deliberately
+ // broader than the session manager's materialization dirty-tracking signature,
+ // because a landing also bakes in AGENTS.md / Claude's instruction append — so
+ // a System-Prompt-only edit must refresh it. Read from the live record;
+ // `projects` is a deliberate re-derive trigger — not read inside the factory,
+ // but a `useProjects()` change means the cached record may have changed.
+ const activeProjectLandingCaptureSignature = useMemo(() => {
+ if (!isProjectScope) return null;
+ const record = getCachedProjectRecordById(activeProjectId);
+ return record ? getProjectLandingCaptureSignature(record) : null;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isProjectScope, activeProjectId, projects]);
+ useRefreshEmptyLandingOnContextSourceChange({
+ activeProjectId,
+ signature: activeProjectLandingCaptureSignature,
+ isLanding,
+ blocking: contextLoadBlocking,
+ draftEmpty: draftIsEmpty,
+ refresh: refreshContextForEmptyLanding,
+ });
+
+ // Project landing lower-area placement: zero chats → the Context body renders
+ // standalone below the composer (no shelf); any chats → the tabbed shelf
+ // (Recent Chats / Context). Decided per landing VISIT (project + session) and
+ // latched, so in-visit churn can't swap the layout under the user:
+ //
+ // - Undecided until the scoped history load settles — rendering neither beats
+ // flashing the wrong branch (e.g. mistaking "not loaded yet" for "no chats").
+ // - One-way upgrade standalone→shelf: the settle can be a stale list from the
+ // previous visit (e.g. "New chat" right after a project's first
+ // conversation), so a refresh that finds chats corrects the layout — that
+ // flip lands within the visit's first moments, while the reverse
+ // (shelf→standalone, e.g. deleting the last chat from the View-all popover
+ // mid-visit) would yank the card out from under an open popover. The shelf
+ // just shows the project empty copy until the next visit re-decides.
+ //
+ // Written during render — the same derive-from-props pattern as the header
+ // latch above.
+ const landingVisitKey = isProjectLanding ? `${activeProjectId}\0${sessionId}` : null;
+ const placementRef = useRef<{ key: string; standalone: boolean } | null>(null);
+ if (landingVisitKey === null) {
+ placementRef.current = null;
+ } else if (chatHistorySettled) {
+ const hasChats = chatHistoryItems.length > 0;
+ if (placementRef.current?.key !== landingVisitKey) {
+ placementRef.current = { key: landingVisitKey, standalone: !hasChats };
+ } else if (placementRef.current.standalone && hasChats) {
+ placementRef.current = { key: landingVisitKey, standalone: false };
+ }
+ } else if (placementRef.current?.key !== landingVisitKey) {
+ placementRef.current = null;
+ }
+ const projectPlacement = placementRef.current;
// The session's main agent — the summarizer and the dedup anchor for
// `@`-mentions. The composer belongs to the ACTIVE session, so anchor to its
@@ -236,11 +493,23 @@ const AgentHomeInternal: React.FC = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
const greeting = useMemo(() => pickRandomGreeting(), [sessionId]);
- // Populate the Recent Chats list whenever the landing is shown (the
- // conversation-state history popover loads on open via the same handler).
+ // Populate the chats list whenever a landing (global or per-project) is shown
+ // (the conversation-state history popover loads on open via the same handler).
+ // `handleLoadChatHistory` changes identity when the scope changes, so entering
+ // a project re-fetches its scoped chats here.
useEffect(() => {
- if (isGlobalLanding) void handleLoadChatHistory();
- }, [isGlobalLanding, handleLoadChatHistory]);
+ if (isLanding) void handleLoadChatHistory();
+ }, [isLanding, handleLoadChatHistory]);
+
+ // Global shelf tab lives HERE (not in the shelf's own state) because the
+ // global shelf unmounts whenever the user leaves the global landing — into a
+ // project, or into a conversation and back via New Chat. This is deliberate
+ // instance-level UI memory: wherever the user left the global shelf, they
+ // return to it (entering a project from the Projects tab and backing out
+ // lands on Projects again instead of snapping to Recent Chats). AgentHome
+ // stays mounted across those switches, so the state survives. null = nothing
+ // picked yet → the shelf resolves to its first selectable tab.
+ const [globalShelfTab, setGlobalShelfTab] = useState(null);
// Chip-shelf sections for the landing. Each body renders lazily (only the open
// section is mounted), so these render closures are cheap to recreate.
@@ -259,24 +528,24 @@ const AgentHomeInternal: React.FC = ({
onDeleteChat={handleDeleteChat}
onOpenSourceFile={handleOpenSourceFile}
onLoadHistory={handleLoadChatHistory}
+ runningChatIds={runningChatIds}
+ attentionChatIds={attentionChatIds}
/>
),
},
{
- // Projects isn't shipped yet: greyed on the right, "Coming soon" on
- // hover, and its list never mounts (the shelf won't make a disabled
- // tab active). Kept wired to ProjectPickerList for when selection lands.
id: "projects",
icon: ,
title: "Projects",
count: projects.length,
- disabled: true,
- disabledTooltip: "Coming soon",
renderBody: () => (
),
},
@@ -284,136 +553,362 @@ const AgentHomeInternal: React.FC = ({
[
projects,
chatHistoryItems,
- handleProjectComingSoon,
+ app,
+ projectUsageManager,
+ handleSelectProject,
+ handleCreateProject,
+ handleProjectDeleted,
handleLoadChat,
handleUpdateChatTitle,
handleDeleteChat,
handleOpenSourceFile,
handleLoadChatHistory,
+ runningChatIds,
+ attentionChatIds,
]
);
+ // Context tab count, computed here because the tab chip must show it while
+ // the tab body is unmounted (the shelf mounts only the active tab). Pure
+ // derivation from the cached record, so edits/drops keep it live.
+ const contextSummary = useMemo(() => buildContextSummary(activeProject), [activeProject]);
+
+ // Per-project landing shelf: the same tabbed card as the global landing, with
+ // scoped sections — Recent Chats first (default tab, identical to the global
+ // tab of the same name, just scoped; chat creation belongs to the tab strip's "+"
+ // on both landings), then the project Context body. Only rendered once the
+ // project has chats; a zero-chat project gets the standalone Context body
+ // instead (see projectPlacement above).
+ const projectLandingSections = useMemo(
+ () => [
+ {
+ id: "project-chats",
+ icon: ,
+ title: "Recent Chats",
+ count: chatHistoryItems.length,
+ renderBody: () => (
+
+ ),
+ },
+ {
+ id: "project-context",
+ icon: ,
+ title: "Context",
+ count: contextSummary.totalItems,
+ renderBody: () => (
+
+ ),
+ },
+ ],
+ [
+ chatHistoryItems,
+ contextSummary.totalItems,
+ app,
+ activeProjectId,
+ rootEl,
+ handleLoadChat,
+ handleUpdateChatTitle,
+ handleDeleteChat,
+ handleOpenSourceFile,
+ handleLoadChatHistory,
+ runningChatIds,
+ attentionChatIds,
+ ]
+ );
+
+ // One composer element, shared by all three surfaces. The project-scope props
+ // drive the send-gate: `activeProjectId` + `contextLoadBlocking` make a send
+ // queue-and-hold while context materializes; `disabled` hard-stops sends when
+ // the active project is orphaned.
+ // Project-context status icon for the composer's badge row. Only a real
+ // (non-orphaned) project scope has context to report; global + orphaned omit it.
+ // `onEditContext` opens the Manage Context modal (the same link/file/tag
+ // manager as the Context tab's Manage button), so the status popover edits
+ // context sources directly rather than detouring through the full Edit Project
+ // form. `onReindex` forces a re-materialization past the failure cache and
+ // calls `refreshContextForEmptyLanding` directly (it re-captures from the
+ // cache without a project-store write, so the source observer never sees it).
+ // `onEditContext`'s save, by contrast, writes the project store and lets the
+ // observer refresh the landing — so it must NOT also refresh directly.
+ const openProjectManageModal = () => {
+ if (!activeProject) return;
+ new ContextManageModal(
+ app,
+ (updated) => {
+ // The save writes through the project store, which the landing's
+ // context-source observer ({@link useRefreshEmptyLandingOnContextSourceChange})
+ // watches — so it refreshes the empty landing on its own. No direct
+ // refresh here, or the store update and this call would each fire a
+ // replaceSessionInPlace (double refresh).
+ void ProjectFileManager.getInstance(app)
+ .updateProject(activeProjectId, updated)
+ .catch((err) => logError("[AgentMode] save context changes failed", err));
+ },
+ activeProject,
+ { enableLinks: true }
+ ).open();
+ };
+
+ const contextStatusIndicator =
+ isProjectScope && !isOrphanedProject && activeProject ? (
+ manager.rematerializeContext(activeProjectId)}
+ onRetryItem={(item) =>
+ manager
+ .rematerializeSource(activeProjectId, {
+ kind: item.cacheKind,
+ source: item.id,
+ })
+ .catch((e) => {
+ logError("[AgentMode] retry source failed", e);
+ return false;
+ })
+ }
+ // The status icon defers this to popover-close so a retry's completion
+ // can't swap the session and yank the popover shut mid-inspection.
+ onRefreshLanding={refreshContextForEmptyLanding}
+ onEditContext={openProjectManageModal}
+ />
+ ) : undefined;
+
+ const composerNode = (
+
+ );
+
+ // Hero: a single title line above the composer — the rotating greeting
+ // (global) or "Chat in " (project landing). An orphaned scope falls
+ // back to the neutral greeting so no "Chat in" line renders against a blank
+ // name. The project landing carries no subtitle: just the title.
+ const showProjectHero = isProjectLanding && !isOrphanedProject;
+ const heroText = showProjectHero ? `Chat in ${projectName}` : greeting;
+ const hero = (
+
+
+ {/* font-[330]: deliberate hero weight, a hair lighter than `font-normal`
+ (400) for the airy greeting. The project's named weight tokens have no
+ slot between light and normal, so this is an intentional one-off. (The
+ "no arbitrary values" rule targets font sizes, not weights.) Promote to
+ a named token if another weight like this appears.
+
+ TruncatedText (not flex-1) so a long project name ellipsizes with a
+ full-text tooltip while a short title keeps the icon+text pair
+ centered — flex-1 would stretch the text box and break the centering. */}
+
+ {heroText}
+
+
+ );
+
return (
-
+
+ {/* Project header sits ABOVE the tab strip: a project scope is just the
+ global layout (tab strip → landing/conversation) with the project
+ header prepended on top. It spans BOTH the project landing and the
+ in-project conversation so sending the first message never drops the
+ project scope from view.
+
+ It animates open/closed on scope change instead of popping: the grid
+ row transitions 1fr↔0fr over an overflow-hidden child, so the header's
+ auto height collapses smoothly and the tab strip + content below slide
+ with it. Kept mounted (not conditionally rendered) so BOTH enter and
+ exit animate; collapsed it's a 0-height, hidden, non-interactive row —
+ visually identical to absent, so the global layout is unchanged. */}
+
+ // pointer-events-none: this is visual feedback only — if the
+ // overlay caught events, every dragover after the first would
+ // target it and inner drop zones (the project context section)
+ // would become unreachable.
+
Drop files here...
)}
- {/* Composer is a position-stable node at a fixed sibling index;
- the surrounding slots toggle so it slides from centered (global
- landing) to bottom (conversation) without remounting and losing
- the draft. On the landing the column scrolls as one unit and the
- flex spacers above/below the composer keep it centered (biased
- lower) while the section titles stay at their natural height
- (never compressed in a narrow sidebar); in the conversation
- state the transcript takes the flex space and the composer sits
- at the bottom. The landing column carries one shared horizontal
- padding (`tw-px-2`) so the composer's border and the section
- rows share the same left/right edge — inner elements add no
- extra horizontal padding of their own. */}
+ {/* Two surfaces share this padded, scrollable column: a landing
+ (global or per-project, laid out by AgentLandingStack —
+ top-anchored composer over a tabbed shelf) and the conversation
+ (transcript + controls + bottom composer). The shared `tw-px-2`
+ keeps the composer border and shelf rows on one left/right edge,
+ so inner elements add no horizontal padding of their own.
+
+ The composer (`composerNode`) is the same element in both
+ branches but sits at different tree positions, so it remounts on
+ the landing→conversation flip. That flip only fires right after
+ a send (which already reset the draft) or on a chat load (which
+ changes `sessionId` and remounts anyway); the per-session draft
+ lives in AgentHome and survives. CAUTION: the flip happens
+ DURING the first turn, so the unmounting composer instance still
+ has an in-flight `runSend` — anything that turn must do on
+ completion (clearing `draft.loading`) MUST NOT be gated on the
+ composer still being mounted (see runSend's finally). */}
- {isGlobalLanding ? (
- <>
- {/* Top spacer at a FIXED fraction of the column height
- (h-1/4), not a flex grow. This top-anchors the composer:
- the spacer is a percentage of the (constant) column
- height, so it stays put when the composer's own height
- changes — e.g. removing the active-note context chip. A
- flex spacer would instead re-divide the freed space and
- push the greeting + composer down (the "top drops" we want
- to avoid). The lower shelf region (flex-1) absorbs the
- change instead, so the composer's lower half and the shelf
- collapse upward while the greeting stays fixed. */}
-
-
-
-
- {/* font-[330]: deliberate hero weight, a hair lighter than
- `font-normal` (400) for the airy greeting. The project's
- named weight tokens have no slot between light and normal,
- so this is an intentional one-off. (The "no arbitrary
- values" rule targets font sizes, not weights.) Promote to a
- named token if another weight like this appears. */}
-
- {greeting}
-
-
-
- >
+ {isLanding ? (
+
+ ) : undefined
+ }
+ context={
+ // Zero-chat project landing: the Context body standalone
+ // below the composer (mutually exclusive with the shelf;
+ // see projectPlacement). Skipped for an orphaned id — the
+ // body renders null for an unknown project and the
+ // wrapper would leave a stray padded gap.
+ isProjectLanding && !isOrphanedProject && projectPlacement?.standalone ? (
+
+
+
+ ) : undefined
+ }
+ shelf={
+ isProjectLanding ? (
+ // Tabbed Recent Chats / Context card, only once the
+ // project has chats. Keyed by project so the selected
+ // tab resets instead of leaking across scope switches
+ // (the global and project shelves share this slot).
+ // null while the placement is undecided (history not
+ // settled) or standalone — the stack drops the shelf
+ // wrapper entirely so no empty gap remains.
+ projectPlacement && !projectPlacement.standalone ? (
+
+ ) : null
+ ) : (
+
+ )
+ }
+ />
) : (
-
+ <>
+
+
+ {composerNode}
+ >
)}
- {isGlobalLanding ? null : (
-
- )}
-
-
-
- {isGlobalLanding ? (
- /* Lower region below the composer holds the tabbed shelf
- (Projects / Recent Chats). flex-1 so it absorbs every change
- in the composer's height: when the composer shrinks (e.g. the
- context chip is removed) this region grows and its top-aligned
- card moves up — the composer above stays top-anchored. The
- shelf is a persistent card that sizes to its content and sits
- at the top of this region. This region owns no scroll of its
- own — on a pane too short to fit the card, the card caps to the
- region height (min-h-0 chain) and scrolls its list internally,
- keeping the tab bar in reach. No extra horizontal padding so
- the card lines up with the composer's border. */
-
-
-
- ) : null}
diff --git a/src/agentMode/ui/AgentHomeSection.tsx b/src/agentMode/ui/AgentHomeSection.tsx
index 2ae6157d..c8c2b553 100644
--- a/src/agentMode/ui/AgentHomeSection.tsx
+++ b/src/agentMode/ui/AgentHomeSection.tsx
@@ -24,8 +24,14 @@ import React, {
* primitives.
*/
-/** Rows shown inline before the rest collapse behind the "View all" popover. */
-export const INLINE_LIMIT = 3;
+/**
+ * Rows shown inline before the rest collapse behind the "View all" popover.
+ * Shared by the Projects and Recent Chats shelf tabs so both preview the same
+ * number of rows — full tabs then land within ~10px of each other, which is
+ * what keeps switching tabs from jumping (the shelf floor only covers the
+ * short/empty end; see AgentHomeShelf.SHELF_BODY_FLOOR_CLASS).
+ */
+export const INLINE_LIMIT = 5;
/**
* Rows rendered per page in the View-all popover. The list grows by this much
@@ -36,7 +42,8 @@ const VIEW_ALL_PAGE_SIZE = 50;
interface AgentHomeCreateRowProps {
label: string;
- onClick: () => void;
+ /** Receives the row's button element so a caller can anchor a popover to it. */
+ onClick: (anchor: HTMLElement) => void;
}
/**
@@ -53,7 +60,7 @@ export const AgentHomeCreateRow = memo(function AgentHomeCreateRow({
- {/* The panel is the scroll container; the inner wrapper carries the fixed
- min-height (create row + 3 item rows + "View all") so the card keeps the
- same height across tabs and when a list is short/empty. With room the
- card sizes to that content and sits at the top of the region; on a pane
+ {/* The panel is the scroll container; the inner wrapper carries the
+ min-height floor (SHELF_BODY_FLOOR_CLASS) so a short/empty list can't
+ collapse the card. It is also a flex column so a section can tw-grow
+ to fill the floor (centering its empty-state copy). With room the card
+ sizes to that content and sits at the top of the region; on a pane
too short to fit it the card shrinks (min-h-0 on the card + min-h-0 here)
and this panel scrolls its list internally while the tab bar stays
pinned — instead of the card being clipped out of reach. The floor lives
@@ -98,7 +140,9 @@ export function AgentHomeShelf({ sections, className }: AgentHomeShelfProps): Re
aria-labelledby={tabId(active.id)}
className="tw-min-h-0 tw-overflow-y-auto"
>
-
{active.renderBody()}
+
+ {active.renderBody()}
+
);
diff --git a/src/agentMode/ui/AgentLandingStack.tsx b/src/agentMode/ui/AgentLandingStack.tsx
new file mode 100644
index 00000000..025a6bfd
--- /dev/null
+++ b/src/agentMode/ui/AgentLandingStack.tsx
@@ -0,0 +1,70 @@
+import React from "react";
+
+interface AgentLandingStackProps {
+ /** Brand icon + greeting (global) or "Chat in " hero (project landing). */
+ hero: React.ReactNode;
+ /**
+ * The composer. Passed as a slot so the same `AgentChatInput` element the
+ * conversation state renders can sit at the frozen landing position.
+ */
+ composer: React.ReactNode;
+ /**
+ * Welcome card (global landing) OR context-load card (project landing) —
+ * mutually exclusive. Floats between the composer and the shelf.
+ */
+ floating?: React.ReactNode;
+ /**
+ * Standalone project Context body (the zero-chat project landing, where no
+ * shelf renders). Project landing only; sits below the floating slot.
+ */
+ context?: React.ReactNode;
+ /**
+ * Tabbed shelf (Recent Chats / Projects, or Project Chats / Context). Omitted
+ * on the zero-chat project landing, where the `context` slot renders instead —
+ * the wrapper (and its top padding) is skipped so no empty gap remains.
+ */
+ shelf?: React.ReactNode;
+}
+
+/**
+ * Pure layout for the Agent Home landing — both the global and per-project
+ * variants render through this so the mount order is frozen in one place:
+ * `hero → composer → [floating] → [context] → shelf`.
+ *
+ * The composer is **top-anchored**, not centered: a fixed-fraction (`h-1/4`)
+ * spacer pins it a quarter of the way down so its own height changes (e.g. a
+ * context chip appearing) don't shift it — the flex-1 shelf region below absorbs
+ * the slack instead. This matches the shipped global landing (decision #2550);
+ * the wireframe's vertically-centered hero is intentionally dropped.
+ *
+ * Presentational only: the parent owns the scrolling/padded column wrapper and
+ * feeds each slot.
+ */
+export function AgentLandingStack({
+ hero,
+ composer,
+ floating,
+ context,
+ shelf,
+}: AgentLandingStackProps): React.ReactElement {
+ return (
+ <>
+
+
{hero}
+
{composer}
+ {/* floating + context own their own padding (`tw-px-2 tw-pb-1`), so the
+ wrappers carry no padding of their own — when a slot's component
+ self-hides (e.g. the context-load card returning null once context is
+ ready) the `shrink-0` wrapper collapses to 0px instead of leaving a stray
+ gap above the shelf. Omitted entirely when the slot is empty, so the
+ global landing's DOM is unchanged. */}
+ {floating ?
{floating}
: null}
+ {context ?
{context}
: null}
+ {shelf ? (
+
{shelf}
+ ) : null}
+ >
+ );
+}
+
+export default AgentLandingStack;
diff --git a/src/agentMode/ui/AgentModeChat.test.tsx b/src/agentMode/ui/AgentModeChat.test.tsx
new file mode 100644
index 00000000..63e846ce
--- /dev/null
+++ b/src/agentMode/ui/AgentModeChat.test.tsx
@@ -0,0 +1,98 @@
+import { AgentModeChat } from "@/agentMode/ui/AgentModeChat";
+import { GLOBAL_SCOPE } from "@/agentMode/session/scope";
+import type { AgentSession } from "@/agentMode/session/AgentSession";
+import type { AgentSessionManager } from "@/agentMode/session/AgentSessionManager";
+import type CopilotPlugin from "@/main";
+import { render, waitFor } from "@testing-library/react";
+import React from "react";
+
+// Stub the descriptor hooks so the effect's `preloadReady`/install gates are
+// satisfied without the real backend registry / jotai atoms. The mock factory
+// names must match the real `use*` exports, so the no-hook `use` prefix is
+// expected here.
+/* eslint-disable @eslint-react/hooks-extra/no-unnecessary-use-prefix */
+jest.mock("@/agentMode/ui/useBackendDescriptor", () => ({
+ useActiveBackendDescriptor: () => ({ id: "claude", openInstallUI: jest.fn() }),
+ useBackendInstallState: () => ({ kind: "installed" }),
+}));
+/* eslint-enable @eslint-react/hooks-extra/no-unnecessary-use-prefix */
+
+// Heavy children are irrelevant to the auto-spawn guard — render nothing.
+jest.mock("@/agentMode/ui/AgentHome", () => ({ AgentHome: () => null }));
+jest.mock("@/agentMode/ui/AgentModeStatus", () => ({ AgentModeStatus: () => null }));
+jest.mock("@/agentMode/ui/AgentChatControls", () => ({ AgentChatControls: () => null }));
+
+const session = (id: string): AgentSession => ({ internalId: id }) as unknown as AgentSession;
+
+interface ManagerStub {
+ activeProjectId: string;
+ scopeSessions: AgentSession[];
+ poolSessions: AgentSession[];
+}
+
+function makeManager({ activeProjectId, scopeSessions, poolSessions }: ManagerStub) {
+ const getOrCreateActiveSession = jest.fn(async () => session("spawned"));
+ const manager = {
+ subscribe: jest.fn(() => () => {}),
+ isPreloadReady: jest.fn(() => true),
+ getSessions: jest.fn(() => poolSessions),
+ getSessionsForScope: jest.fn(() => scopeSessions),
+ getActiveProjectId: jest.fn(() => activeProjectId),
+ getIsStarting: jest.fn(() => false),
+ getLastError: jest.fn(() => null),
+ getActiveSession: jest.fn(() => null),
+ getActiveChatUIState: jest.fn(() => null),
+ getOrCreateActiveSession,
+ } as unknown as AgentSessionManager & { getOrCreateActiveSession: jest.Mock };
+ return { manager, getOrCreateActiveSession };
+}
+
+function renderChat(manager: AgentSessionManager) {
+ const plugin = { app: {}, agentSessionManager: manager } as unknown as CopilotPlugin;
+ return render(
+ {}} updateUserMessageHistory={() => {}} />
+ );
+}
+
+describe("AgentModeChat auto-spawn guard (scope-aware)", () => {
+ it("regression: spawns the current project scope's session even when another scope still has sessions", async () => {
+ // The closed scope (project-1) is empty, but the global pool still holds a
+ // session. A whole-pool guard would skip the spawn and strand the pane on
+ // the no-session fallback; the scope-aware guard must re-spawn project-1.
+ const { manager, getOrCreateActiveSession } = makeManager({
+ activeProjectId: "project-1",
+ scopeSessions: [],
+ poolSessions: [session("global-1")],
+ });
+
+ renderChat(manager);
+
+ await waitFor(() => expect(getOrCreateActiveSession).toHaveBeenCalledTimes(1));
+ });
+
+ it("spawns the global scope's session when global is empty but a project still has sessions", async () => {
+ const { manager, getOrCreateActiveSession } = makeManager({
+ activeProjectId: GLOBAL_SCOPE,
+ scopeSessions: [],
+ poolSessions: [session("project-1-s1")],
+ });
+
+ renderChat(manager);
+
+ await waitFor(() => expect(getOrCreateActiveSession).toHaveBeenCalledTimes(1));
+ });
+
+ it("does not spawn when the current scope already has a session (single-scope behavior unchanged)", async () => {
+ const { manager, getOrCreateActiveSession } = makeManager({
+ activeProjectId: GLOBAL_SCOPE,
+ scopeSessions: [session("global-1")],
+ poolSessions: [session("global-1")],
+ });
+
+ renderChat(manager);
+
+ // Flush effects, then assert the guard short-circuited.
+ await waitFor(() => expect(manager.getSessionsForScope).toHaveBeenCalled());
+ expect(getOrCreateActiveSession).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/agentMode/ui/AgentModeChat.tsx b/src/agentMode/ui/AgentModeChat.tsx
index 13728c7b..7cc0c132 100644
--- a/src/agentMode/ui/AgentModeChat.tsx
+++ b/src/agentMode/ui/AgentModeChat.tsx
@@ -53,7 +53,14 @@ export const AgentModeChat: React.FC = ({
React.useEffect(() => {
if (!manager) return;
if (!preloadReady) return;
- if (manager.getSessions().length > 0) return;
+ // Gate on the *current scope's* sessions, not the whole pool: closing the
+ // last session in a scope (project or global) nulls the active session but
+ // keeps `activeProjectId` put, so we must re-spawn that scope's landing even
+ // when another scope still holds sessions. A whole-pool guard would leave the
+ // pane on the no-session fallback instead of the scope's landing.
+ // `getOrCreateActiveSession` is scope-aware and the manager de-dupes per
+ // scope, so this can't double-spawn.
+ if (manager.getSessionsForScope(manager.getActiveProjectId()).length > 0) return;
if (manager.getIsStarting()) return;
if (manager.getLastError()) return;
if (installState.kind === "absent") return;
diff --git a/src/agentMode/ui/AgentProjectCreateForm.test.tsx b/src/agentMode/ui/AgentProjectCreateForm.test.tsx
new file mode 100644
index 00000000..32d077e9
--- /dev/null
+++ b/src/agentMode/ui/AgentProjectCreateForm.test.tsx
@@ -0,0 +1,83 @@
+import React from "react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import {
+ AgentProjectCreateForm,
+ makeNewProjectConfig,
+} from "@/agentMode/ui/AgentProjectCreateForm";
+
+beforeAll(() => {
+ // jsdom's `crypto` has no `randomUUID`; `makeNewProjectConfig` needs it. Use a
+ // counter so each call is unique (the real runtime is Electron/Obsidian).
+ const cryptoObj = window.crypto as { randomUUID?: () => string };
+ if (typeof cryptoObj.randomUUID !== "function") {
+ let counter = 0;
+ cryptoObj.randomUUID = () => `00000000-0000-4000-8000-${String(++counter).padStart(12, "0")}`;
+ }
+});
+
+function renderForm(props: Partial> = {}) {
+ const onSave = props.onSave ?? jest.fn().mockResolvedValue(undefined);
+ const onCancel = props.onCancel ?? jest.fn();
+ render();
+ return { onSave, onCancel };
+}
+
+describe("AgentProjectCreateForm", () => {
+ it("disables Create until a name is entered", () => {
+ renderForm();
+ const create = screen.getByText("Create").closest("button") as HTMLButtonElement;
+ expect(create.disabled).toBe(true);
+
+ fireEvent.change(screen.getByPlaceholderText("Project name"), {
+ target: { value: " Research " },
+ });
+ expect(create.disabled).toBe(false);
+ });
+
+ it("saves the trimmed name", async () => {
+ const { onSave } = renderForm();
+ fireEvent.change(screen.getByPlaceholderText("Project name"), {
+ target: { value: " Research " },
+ });
+ fireEvent.click(screen.getByText("Create"));
+ await waitFor(() => expect(onSave).toHaveBeenCalledWith({ name: "Research" }));
+ });
+
+ it("submits on Enter from the name field", async () => {
+ const { onSave } = renderForm();
+ const input = screen.getByPlaceholderText("Project name");
+ fireEvent.change(input, { target: { value: "Research" } });
+ fireEvent.keyDown(input, { key: "Enter" });
+ await waitFor(() => expect(onSave).toHaveBeenCalledWith({ name: "Research" }));
+ });
+
+ it("cancels without saving", () => {
+ const { onCancel, onSave } = renderForm();
+ fireEvent.click(screen.getByText("Cancel"));
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ expect(onSave).not.toHaveBeenCalled();
+ });
+});
+
+describe("makeNewProjectConfig", () => {
+ it("builds a name-only config with the Agent-Mode-empty defaults", () => {
+ const project = makeNewProjectConfig("Research");
+ expect(project.name).toBe("Research");
+ // Agent Mode never reads the CAG model selector → left empty, not defaulted.
+ expect(project.systemPrompt).toBe("");
+ expect(project.projectModelKey).toBe("");
+ expect(project.modelConfigs).toEqual({});
+ expect(project.contextSource).toEqual({
+ inclusions: "",
+ exclusions: "",
+ webUrls: "",
+ youtubeUrls: "",
+ });
+ expect(project.created).toBe(project.UsageTimestamps);
+ expect(typeof project.created).toBe("number");
+ });
+
+ it("assigns a unique id per call", () => {
+ expect(makeNewProjectConfig("A").id).not.toBe(makeNewProjectConfig("A").id);
+ });
+});
diff --git a/src/agentMode/ui/AgentProjectCreateForm.tsx b/src/agentMode/ui/AgentProjectCreateForm.tsx
new file mode 100644
index 00000000..69e3d038
--- /dev/null
+++ b/src/agentMode/ui/AgentProjectCreateForm.tsx
@@ -0,0 +1,119 @@
+import { ProjectConfig } from "@/aiParams";
+import { Button } from "@/components/ui/button";
+import { FormField } from "@/components/ui/form-field";
+import { Input } from "@/components/ui/input";
+import { err2String, randomUUID } from "@/utils";
+import { Notice } from "obsidian";
+import React, { useState } from "react";
+
+interface AgentProjectCreateFormProps {
+ /**
+ * Optional card title + one-line subtitle. The anchored create panel renders
+ * the form as a titled card (design B.1); when `title` is set the name input
+ * drops its "Name" label, since the subtitle already prompts for it.
+ */
+ title?: string;
+ subtitle?: string;
+ /** Resolve to close the panel; reject to surface a Notice and stay open. */
+ onSave: (data: { name: string }) => Promise;
+ onCancel: () => void;
+}
+
+/**
+ * Name-only "new project" form body, hosted by the anchored create panel
+ * ({@link CreateProjectPanel}). Full project editing lives in AddProjectModal
+ * (agent variant) — this form only creates. Exported for unit tests; the caller
+ * owns persistence (mapping the name onto a {@link ProjectConfig}).
+ */
+export function AgentProjectCreateForm({
+ title,
+ subtitle,
+ onSave,
+ onCancel,
+}: AgentProjectCreateFormProps): React.ReactElement {
+ const [name, setName] = useState("");
+ const [isSaving, setIsSaving] = useState(false);
+
+ const canSave = name.trim().length > 0 && !isSaving;
+
+ const handleSave = async () => {
+ if (name.trim().length === 0 || isSaving) return;
+ setIsSaving(true);
+ try {
+ await onSave({ name: name.trim() });
+ } catch (e) {
+ // Reason: createProject rejects on duplicate name etc. — keep the panel
+ // open so the user can correct the field instead of losing input.
+ new Notice(err2String(e));
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const nameInput = (
+ setName(e.target.value)}
+ placeholder="Project name"
+ autoFocus
+ onKeyDown={(e) => {
+ // Enter submits straight from the single name field.
+ if (e.key === "Enter") {
+ e.preventDefault();
+ void handleSave();
+ }
+ }}
+ className="tw-w-full"
+ />
+ );
+
+ return (
+
+ {title && (
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
+ )}
+ {/* With a title card (create panel) the subtitle is the prompt, so the
+ input drops the redundant "Name" label; otherwise keep the field. */}
+ {title ? (
+ nameInput
+ ) : (
+
+ {nameInput}
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Build a fresh, valid {@link ProjectConfig} for a name-only create. Agent Mode
+ * ignores the CAG model selector, so it's left empty rather than forcing a
+ * meaningless choice at creation time. The single source of truth for the
+ * new-project shape, used by the anchored create panel (`CreateProjectPanel`).
+ */
+export function makeNewProjectConfig(name: string): ProjectConfig {
+ const now = Date.now();
+ return {
+ id: randomUUID(),
+ name,
+ systemPrompt: "",
+ projectModelKey: "",
+ modelConfigs: {},
+ contextSource: { inclusions: "", exclusions: "", webUrls: "", youtubeUrls: "" },
+ created: now,
+ UsageTimestamps: now,
+ };
+}
diff --git a/src/agentMode/ui/AgentProjectHeader.test.tsx b/src/agentMode/ui/AgentProjectHeader.test.tsx
new file mode 100644
index 00000000..1aaf5ad7
--- /dev/null
+++ b/src/agentMode/ui/AgentProjectHeader.test.tsx
@@ -0,0 +1,57 @@
+import React from "react";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { TooltipProvider } from "@/components/ui/tooltip";
+import { AgentProjectHeader } from "@/agentMode/ui/AgentProjectHeader";
+
+// TruncatedText's Radix tooltip portals into Obsidian's `activeDocument` global,
+// which jsdom doesn't provide — alias it to the test document.
+beforeAll(() => {
+ (window as unknown as { activeDocument: Document }).activeDocument = window.document;
+});
+
+// TruncatedText renders a tooltip trigger, so wrap in the provider it expects.
+function renderHeader(props: Partial> = {}) {
+ const onExit = props.onExit ?? jest.fn();
+ const menu = "menu" in props ? props.menu : ;
+ render(
+
+
+
+ );
+ return { onExit };
+}
+
+describe("AgentProjectHeader", () => {
+ it("shows the live project name", () => {
+ renderHeader({ projectName: "My Research" });
+ expect(screen.getByText("My Research")).toBeTruthy();
+ });
+
+ it("exits the project when the back affordance is clicked", () => {
+ const { onExit } = renderHeader();
+ fireEvent.click(screen.getByRole("button", { name: "Leave project" }));
+ expect(onExit).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders the provided options menu in the trailing slot", () => {
+ renderHeader({ menu: });
+ expect(screen.getByLabelText("Project options")).toBeTruthy();
+ });
+
+ it("degrades to an escape hatch when the project is orphaned", () => {
+ const { onExit } = renderHeader({ projectName: "Gone", orphaned: true });
+ // No stale name or options menu pointing at a deleted project.
+ expect(screen.queryByText("Gone")).toBeNull();
+ expect(screen.queryByLabelText("Project options")).toBeNull();
+ expect(screen.getByText("This project no longer exists")).toBeTruthy();
+ // The back affordance still works so the user can leave the dead scope.
+ fireEvent.click(screen.getByRole("button", { name: "Leave project" }));
+ expect(onExit).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/agentMode/ui/AgentProjectHeader.tsx b/src/agentMode/ui/AgentProjectHeader.tsx
new file mode 100644
index 00000000..f466007d
--- /dev/null
+++ b/src/agentMode/ui/AgentProjectHeader.tsx
@@ -0,0 +1,77 @@
+import { ProjectIconTile } from "@/agentMode/ui/ProjectIconTile";
+import { TruncatedText } from "@/components/TruncatedText";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import { ChevronLeft } from "lucide-react";
+import React, { memo } from "react";
+
+interface AgentProjectHeaderProps {
+ /** Stable project id — drives the identity tile's color (same hue as the Projects tab). */
+ projectId: string;
+ /** Live project name (read from `useProjects` by the parent so renames reflect). */
+ projectName: string;
+ /** Leave the project workspace back to the global scope. */
+ onExit: () => void;
+ /**
+ * Per-project options control (the `⋯` overflow menu: Edit / Reveal / Delete),
+ * rendered in the trailing slot. Passed as a node so this stays presentational
+ * — the parent owns the menu component and its handlers. Omitted when orphaned.
+ */
+ menu?: React.ReactNode;
+ /**
+ * The active project's record is gone (folder/`project.md` deleted while the
+ * user was inside it). Degrades to just the `‹` escape hatch — no stale
+ * name, tile, or menu pointing at a project that no longer exists.
+ */
+ orphaned?: boolean;
+ className?: string;
+}
+
+/**
+ * Thin workspace header shown above the chat surface whenever a project scope is
+ * active (both the project landing and an in-project conversation). Presentational
+ * only: the parent owns scope state and feeds the live `projectName`.
+ */
+export const AgentProjectHeader = memo(
+ ({
+ projectId,
+ projectName,
+ onExit,
+ menu,
+ orphaned = false,
+ className,
+ }: AgentProjectHeaderProps): React.ReactElement => (
+
+ )
+);
+
+AgentProjectHeader.displayName = "AgentProjectHeader";
diff --git a/src/agentMode/ui/AgentProjectRowActions.tsx b/src/agentMode/ui/AgentProjectRowActions.tsx
new file mode 100644
index 00000000..c6841ceb
--- /dev/null
+++ b/src/agentMode/ui/AgentProjectRowActions.tsx
@@ -0,0 +1,160 @@
+import { ProjectConfig } from "@/aiParams";
+import { AddProjectModal } from "@/components/modals/project/AddProjectModal";
+import { ConfirmModal } from "@/components/modals/ConfirmModal";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import { logError } from "@/logger";
+import { getProjectFolderPath } from "@/projects/projectPaths";
+import { ProjectFileManager } from "@/projects/ProjectFileManager";
+import { getCachedProjectRecordById } from "@/projects/state";
+import { FolderSearch, Pencil, Trash2 } from "lucide-react";
+import { App, Notice, TFolder } from "obsidian";
+import React, { memo } from "react";
+
+/**
+ * Reveal a project's folder in Obsidian's file explorer via the internal
+ * file-explorer plugin (same approach as the projects migration flow). Hidden
+ * (dot-prefixed) folders aren't in the vault cache, so reveal silently no-ops —
+ * surface a Notice instead of failing quietly.
+ */
+export function revealProjectFolder(app: App, project: ProjectConfig): void {
+ const record = getCachedProjectRecordById(project.id);
+ const folderPath = record ? getProjectFolderPath(record.folderName) : null;
+ const folder = folderPath ? app.vault.getAbstractFileByPath(folderPath) : null;
+ if (folder instanceof TFolder) {
+ const fileExplorer = (
+ app as unknown as {
+ internalPlugins?: {
+ getPluginById?: (
+ id: string
+ ) =>
+ | { enabled?: boolean; instance?: { revealInFolder?: (folder: TFolder) => void } }
+ | undefined;
+ };
+ }
+ ).internalPlugins?.getPluginById?.("file-explorer");
+ if (fileExplorer?.enabled && fileExplorer.instance?.revealInFolder) {
+ fileExplorer.instance.revealInFolder(folder);
+ return;
+ }
+ }
+ new Notice(`Can't reveal "${project.name}" — its folder isn't visible in the file explorer.`);
+}
+
+interface AgentProjectRowActionsProps {
+ app: App;
+ project: ProjectConfig;
+ /** Fired after a successful edit (caller refreshes its project list/cache). */
+ onEdited?: (project: ProjectConfig) => void;
+ /** Fired after a successful delete (caller may exit the scope if it was active). */
+ onDeleted?: (projectId: string) => void;
+ className?: string;
+}
+
+/**
+ * Inline action cluster for a project row: Reveal in vault · Edit · Delete.
+ * Drop into {@link AgentHomeListRow}'s `trailing` slot — the row reveals it on
+ * hover / keyboard focus in the relative time's place, the same way the Recent
+ * Chats rows surface their open / rename / delete buttons, so the two shelf tabs
+ * read as one component family (this replaces the older `⋯` overflow dropdown).
+ *
+ * Edit and Delete deliberately stay modal-backed rather than inline: Edit is a
+ * multi-field form (the full {@link AddProjectModal}), and Delete keeps the
+ * {@link ConfirmModal} whose copy reassures that the notes survive — a destructive
+ * project op warrants the heavier confirm than a chat's inline two-step. The
+ * persistence ops (`updateProject` / `deleteProject`) run here; the caller wires
+ * `onEdited` / `onDeleted` for follow-up (refresh, exit an orphaned scope).
+ *
+ * Each button stops propagation so reveal / edit / delete never also fires the
+ * row's open-project action. The trailing slot already guards this, but keeping
+ * it on the buttons leaves the cluster self-contained for reuse outside the row.
+ */
+export const AgentProjectRowActions = memo(
+ ({
+ app,
+ project,
+ onEdited,
+ onDeleted,
+ className,
+ }: AgentProjectRowActionsProps): React.ReactElement => {
+ const handleEdit = () => {
+ // Agent edit reuses the full project modal MINUS the model card + CAG
+ // processing status (agentMode). No `plugin` → no CAG retry affordances.
+ new AddProjectModal(
+ app,
+ async (next) => {
+ const updated = await ProjectFileManager.getInstance(app).updateProject(project.id, next);
+ onEdited?.(updated.project);
+ },
+ project,
+ undefined,
+ true
+ ).open();
+ };
+
+ const handleDelete = () => {
+ new ConfirmModal(
+ app,
+ async () => {
+ try {
+ await ProjectFileManager.getInstance(app).deleteProject(project.id);
+ onDeleted?.(project.id);
+ } catch (e) {
+ logError("[AgentProjectRowActions] deleteProject failed", e);
+ new Notice(`Failed to delete project "${project.name}".`);
+ }
+ },
+ `Delete project "${project.name}"? This removes its configuration; your notes stay in the vault.`,
+ "Delete project",
+ "Delete",
+ "Cancel"
+ ).open();
+ };
+
+ return (
+
+
+
+
+
+ );
+ }
+);
+
+AgentProjectRowActions.displayName = "AgentProjectRowActions";
diff --git a/src/agentMode/ui/AgentTabStrip.tsx b/src/agentMode/ui/AgentTabStrip.tsx
index d811aead..04aae400 100644
--- a/src/agentMode/ui/AgentTabStrip.tsx
+++ b/src/agentMode/ui/AgentTabStrip.tsx
@@ -126,7 +126,10 @@ export const AgentTabStrip: React.FC = ({ manager }) => {
const [, setTick] = React.useState(0);
React.useEffect(() => manager.subscribe(() => setTick((v) => v + 1)), [manager]);
- const sessions = manager.getSessions();
+ // Scope the strip to the active workspace: a project shows only its own
+ // sessions, the global workspace shows the global ones. `getSessions()` stays
+ // reserved for whole-pool consumers (draft prune / auto-spawn), per §2.4.
+ const sessions = manager.getSessionsForScope(manager.getActiveProjectId());
const activeId = manager.getActiveSession()?.internalId ?? null;
const isCreating = manager.getIsStarting();
const [renamingId, setRenamingId] = React.useState(null);
diff --git a/src/agentMode/ui/AgentTrailView.tsx b/src/agentMode/ui/AgentTrailView.tsx
index 74d462fe..3ff983d0 100644
--- a/src/agentMode/ui/AgentTrailView.tsx
+++ b/src/agentMode/ui/AgentTrailView.tsx
@@ -208,20 +208,23 @@ interface PlanPillProps {
}
// Inline plan-checklist pill for `kind: "plan"` parts. Permission-gated
-// plan proposals are handled separately by `PlanProposalCard`.
-const PlanPill: React.FC = ({ entries }) => (
-
-
Plan
-
- {entries.map((e, i) => (
- // eslint-disable-next-line @eslint-react/no-array-index-key -- plan entries are positional and may share content
-
- {planEntryIcon(e.status)}
- {e.content}
-
- ))}
-
-
-);
+// plan proposals are handled separately by `PlanProposalCard`. An empty
+// entries list (the agent deleted every task) renders nothing, not an
+// empty shell.
+const PlanPill: React.FC = ({ entries }) =>
+ entries.length === 0 ? null : (
+
+
Plan
+
+ {entries.map((e, i) => (
+ // eslint-disable-next-line @eslint-react/no-array-index-key -- plan entries are positional and may share content
+
+ {planEntryIcon(e.status)}
+ {e.content}
+
+ ))}
+
+
+ );
export default AgentTrail;
diff --git a/src/agentMode/ui/AgentWelcomeCard.test.tsx b/src/agentMode/ui/AgentWelcomeCard.test.tsx
new file mode 100644
index 00000000..abeb53aa
--- /dev/null
+++ b/src/agentMode/ui/AgentWelcomeCard.test.tsx
@@ -0,0 +1,25 @@
+import React from "react";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { AgentWelcomeCard } from "@/agentMode/ui/AgentWelcomeCard";
+
+describe("AgentWelcomeCard", () => {
+ it("renders the project nudge copy and a create CTA", () => {
+ render();
+ expect(screen.getByText("Try a project")).toBeTruthy();
+ expect(screen.getByText("New project")).toBeTruthy();
+ });
+
+ it("invokes onCreate when the CTA is clicked", () => {
+ const onCreate = jest.fn();
+ render();
+ fireEvent.click(screen.getByText("New project"));
+ expect(onCreate).toHaveBeenCalledTimes(1);
+ });
+
+ it("invokes onDismiss from the × control", () => {
+ const onDismiss = jest.fn();
+ render();
+ fireEvent.click(screen.getByLabelText("Dismiss"));
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/agentMode/ui/AgentWelcomeCard.tsx b/src/agentMode/ui/AgentWelcomeCard.tsx
new file mode 100644
index 00000000..85f3edce
--- /dev/null
+++ b/src/agentMode/ui/AgentWelcomeCard.tsx
@@ -0,0 +1,70 @@
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import { FolderPlus, Sparkles, X } from "lucide-react";
+import React, { memo } from "react";
+
+interface AgentWelcomeCardProps {
+ /** Start the name-only project creation flow, anchored to the trigger button. */
+ onCreate: (anchor: HTMLElement) => void;
+ /**
+ * Dismiss the card. The parent persists this to `agentMode.welcomeDismissed`
+ * so the nudge never returns; the card itself holds no dismissed state.
+ */
+ onDismiss: () => void;
+ className?: string;
+}
+
+/**
+ * "Try a project" nudge for the global Agent Home landing. The parent floats it
+ * between the composer and the shelf and only mounts it when no projects exist
+ * and `welcomeDismissed` is false — this component is pure presentation, owning
+ * neither the visibility condition nor the persisted dismissal (it just signals
+ * intent via {@link AgentWelcomeCardProps.onDismiss}).
+ *
+ * Visual authority: design-handoff stage A.1. We take its copy/layout but not the
+ * sketch styling (no orange "NEW" badge, no handwriting) — Obsidian theme vars +
+ * existing `tw-` tokens only.
+ */
+export const AgentWelcomeCard = memo(
+ ({ onCreate, onDismiss, className }: AgentWelcomeCardProps): React.ReactElement => (
+
+
+
+
+
+ Try a project
+
+
+
+ Group notes, URLs, PDFs, and folders into a focused context. The agent answers only within
+ those materials, and each project keeps its own chat history.
+
+
+
+
+ )
+);
+
+AgentWelcomeCard.displayName = "AgentWelcomeCard";
diff --git a/src/agentMode/ui/CreateProjectPanel.tsx b/src/agentMode/ui/CreateProjectPanel.tsx
new file mode 100644
index 00000000..db9289c6
--- /dev/null
+++ b/src/agentMode/ui/CreateProjectPanel.tsx
@@ -0,0 +1,137 @@
+import { AgentProjectCreateForm } from "@/agentMode/ui/AgentProjectCreateForm";
+import { computeVerticalPlacement } from "@/utils/panelPlacement";
+import * as React from "react";
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+
+interface CreateProjectPanelProps {
+ /** The clicked trigger ("+ New project" row or Welcome button) to anchor to. */
+ anchorEl: HTMLElement;
+ onClose: () => void;
+ /**
+ * Persist the new project. Resolve to close the panel; reject to keep it open
+ * with a Notice (e.g. a duplicate name).
+ */
+ onSave: (data: { name: string }) => Promise;
+}
+
+/** Viewport-edge margin / anchor gap — matches Quick Command's placement inputs. */
+const MARGIN = 12;
+const GAP = 6;
+const PANEL_WIDTH = 260;
+/** First-paint height guess, replaced by a real measurement in useLayoutEffect. */
+const ESTIMATED_HEIGHT = 140;
+
+interface PanelPosition {
+ top: number;
+ left: number;
+}
+
+/**
+ * A lightweight, anchored "new project" popover. Unlike a screen-centered
+ * modal, it opens next to the trigger and flips below/above/center by available
+ * space — reusing Quick Command's placement
+ * brain ({@link computeVerticalPlacement}) and the name-only create form
+ * ({@link AgentProjectCreateForm}). Portaled into the trigger's own
+ * document so it lands in the right window when the view is popped out.
+ */
+export function CreateProjectPanel({
+ anchorEl,
+ onClose,
+ onSave,
+}: CreateProjectPanelProps): React.ReactPortal {
+ const panelRef = useRef(null);
+ const doc = anchorEl.ownerDocument;
+ const win = doc.defaultView ?? window;
+
+ // Place from the trigger's rect. The same rect is fed as both the top and
+ // bottom anchor, so the helper reads "below" = under the trigger and "above"
+ // = over it; with neither fitting it falls back to viewport center.
+ const computePosition = useCallback(
+ (panelHeight: number): PanelPosition => {
+ const rect = anchorEl.getBoundingClientRect();
+ const { top } = computeVerticalPlacement({
+ scrollRect: { top: 0, bottom: win.innerHeight },
+ visibleBottom: rect,
+ visibleTop: rect,
+ panelHeight,
+ margin: MARGIN,
+ gap: GAP,
+ viewportHeight: win.innerHeight,
+ });
+ // Center the panel on the trigger's horizontal midpoint. The "+ New
+ // project" row and Welcome button span the project pane, so their midpoint
+ // is the pane's center — reads better than hugging the left edge.
+ const centeredLeft = rect.left + rect.width / 2 - PANEL_WIDTH / 2;
+ const left = Math.max(MARGIN, Math.min(centeredLeft, win.innerWidth - PANEL_WIDTH - MARGIN));
+ return { top, left };
+ },
+ [anchorEl, win]
+ );
+
+ const [position, setPosition] = useState(() => computePosition(ESTIMATED_HEIGHT));
+
+ // Re-place with the panel's REAL height once mounted (and on resize): a height
+ // estimate smaller than the real panel would put an "above" placement on top
+ // of the trigger. Mirrors QuickAskOverlay, which measures then finalizes.
+ //
+ // DESIGN NOTE: re-anchors on resize only, NOT on scroll — this is a transient
+ // name+Enter popover whose primary exits are submit / Esc / outside-click, so
+ // a mid-edit scroll of the landing is an edge case not worth a scroll listener
+ // or ResizeObserver. If scroll-follow is ever needed, prefer close-on-scroll
+ // over reposition.
+ useLayoutEffect(() => {
+ const reposition = () =>
+ setPosition(computePosition(panelRef.current?.offsetHeight ?? ESTIMATED_HEIGHT));
+ reposition();
+ win.addEventListener("resize", reposition);
+ return () => win.removeEventListener("resize", reposition);
+ }, [computePosition, win]);
+
+ // Escape + click-outside dismissal (no shared hook exists; mirrors
+ // QuickAskOverlay). `defaultPrevented` lets an inner control consume Escape
+ // first; a pointerdown on the trigger itself is ignored so it doesn't
+ // close-then-reopen.
+ useEffect(() => {
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape" && !e.defaultPrevented) {
+ e.preventDefault();
+ onClose();
+ }
+ };
+ const onPointerDown = (e: MouseEvent) => {
+ const target = e.target as Node | null;
+ if (target && !panelRef.current?.contains(target) && !anchorEl.contains(target)) {
+ onClose();
+ }
+ };
+ win.addEventListener("keydown", onKeyDown);
+ doc.addEventListener("mousedown", onPointerDown, true);
+ return () => {
+ win.removeEventListener("keydown", onKeyDown);
+ doc.removeEventListener("mousedown", onPointerDown, true);
+ };
+ }, [anchorEl, doc, win, onClose]);
+
+ return createPortal(
+
+
+
,
+ doc.body
+ );
+}
+
+export default CreateProjectPanel;
diff --git a/src/agentMode/ui/GlobalRecentChatsSection.test.tsx b/src/agentMode/ui/GlobalRecentChatsSection.test.tsx
new file mode 100644
index 00000000..0a69eb68
--- /dev/null
+++ b/src/agentMode/ui/GlobalRecentChatsSection.test.tsx
@@ -0,0 +1,112 @@
+import React from "react";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { GlobalRecentChatsSection } from "@/agentMode/ui/GlobalRecentChatsSection";
+
+// jsdom lacks Obsidian's `activeDocument`; the section's View-all popover portals
+// into it. The empty-state paths under test never open it, but alias defensively.
+beforeAll(() => {
+ (window as unknown as { activeDocument: Document }).activeDocument = window.document;
+});
+
+const noop = async () => {};
+
+function renderSection(props: Partial> = {}) {
+ return render(
+
+ );
+}
+
+// The attention dot is `aria-hidden` (purely decorative overlay), so query it
+// by its accent class the way ChatIconWithAttention paints it.
+function queryAttentionDot(container: HTMLElement): Element | null {
+ return container.querySelector(".tw-bg-interactive-accent");
+}
+
+function makeItem(
+ id: string
+): React.ComponentProps["items"][number] {
+ // `lastAccessedAt` set to "now" so the relative-time label renders the stable
+ // `now` bucket regardless of when the test runs.
+ return {
+ id,
+ title: `Chat ${id}`,
+ createdAt: new Date(),
+ lastAccessedAt: new Date(),
+ };
+}
+
+describe("GlobalRecentChatsSection", () => {
+ it("defaults to the global empty-state copy", () => {
+ renderSection();
+ expect(screen.getByText("No recent chats")).toBeTruthy();
+ });
+
+ it("uses project-scoped empty-state copy in the project variant", () => {
+ renderSection({ variant: "project" });
+ expect(screen.getByText("No chats in this project yet")).toBeTruthy();
+ });
+
+ it("applies the optional title as the section's accessible label", () => {
+ renderSection({ variant: "project", title: "Project Chats" });
+ expect(screen.getByLabelText("Project Chats")).toBeTruthy();
+ });
+
+ it("renders a running spinner instead of the time for a backgrounded session", () => {
+ const item = makeItem("running-1");
+ renderSection({ items: [item], runningChatIds: new Set([item.id]) });
+ expect(screen.getByLabelText("Running")).toBeTruthy();
+ expect(screen.queryByText("now")).toBeNull();
+ });
+
+ it("renders the relative time (no spinner) when the session is not running", () => {
+ const item = makeItem("idle-1");
+ renderSection({ items: [item], runningChatIds: new Set() });
+ expect(screen.queryByLabelText("Running")).toBeNull();
+ expect(screen.getByText("now")).toBeTruthy();
+ });
+
+ it("shows the attention dot from the live set even when the item snapshot lacks it", () => {
+ // The handoff case: a backgrounded session finished AFTER the history items
+ // were loaded — the stale snapshot says no attention, the live set says yes.
+ const item = makeItem("done-live");
+ expect(item.needsAttention).toBeUndefined();
+ const { container } = renderSection({
+ items: [item],
+ attentionChatIds: new Set([item.id]),
+ });
+ expect(queryAttentionDot(container)).not.toBeNull();
+ });
+
+ it("shows no attention dot when neither the snapshot nor the live set flags it", () => {
+ const item = makeItem("plain-1");
+ const { container } = renderSection({ items: [item], attentionChatIds: new Set() });
+ expect(queryAttentionDot(container)).toBeNull();
+ });
+
+ it("caps the inline preview at 5 chats and offers a View-all trigger on overflow", () => {
+ const items = Array.from({ length: 7 }, (_, i) => makeItem(`overflow-${i}`));
+ renderSection({ items });
+ expect(screen.getAllByText(/^Chat overflow-/)).toHaveLength(5);
+ expect(screen.getByText("View all chats")).toBeTruthy();
+ });
+
+ it("shows every match (no cap, no View-all) while searching", () => {
+ const items = Array.from({ length: 7 }, (_, i) => makeItem(`search-${i}`));
+ renderSection({ items });
+ fireEvent.change(screen.getByPlaceholderText("Search chats..."), {
+ target: { value: "Chat search" },
+ });
+ expect(screen.getAllByText(/^Chat search-/)).toHaveLength(7);
+ expect(screen.queryByText("View all chats")).toBeNull();
+ });
+});
diff --git a/src/agentMode/ui/GlobalRecentChatsSection.tsx b/src/agentMode/ui/GlobalRecentChatsSection.tsx
index 62b86be1..2318b89b 100644
--- a/src/agentMode/ui/GlobalRecentChatsSection.tsx
+++ b/src/agentMode/ui/GlobalRecentChatsSection.tsx
@@ -1,19 +1,57 @@
import { backendRegistry } from "@/agentMode/backends/registry";
+import { INLINE_LIMIT } from "@/agentMode/ui/AgentHomeSection";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { SearchBar } from "@/components/ui/SearchBar";
-import { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover";
+import {
+ ChatHistoryItem,
+ ChatHistoryPopover,
+} from "@/components/chat-components/ChatHistoryPopover";
+import { ChatIconWithAttention } from "@/components/chat-components/ChatIconWithAttention";
import { cn } from "@/lib/utils";
import { isNativeChatId } from "@/utils/nativeChatId";
import { formatCompactRelativeTime } from "@/utils/formatRelativeTime";
import { sortByStrategy } from "@/utils/recentUsageManager";
-import { ArrowUpRight, Check, Edit2, MessageCircle, Trash2, X } from "lucide-react";
+import {
+ ArrowUpRight,
+ Check,
+ ChevronRight,
+ Edit2,
+ LoaderCircle,
+ MessageCircle,
+ Trash2,
+ X,
+} from "lucide-react";
import React, { memo, useCallback, useEffect, useMemo, useState } from "react";
+/** Stable noop for rows that aren't being renamed (they never invoke onSaveEdit). */
+const NOOP_SAVE = (): void => {};
+
+/**
+ * Which landing this section renders under. `global` is the original Agent Home
+ * "Recent Chats" tab (every chat, flat). `project` is the per-project landing's
+ * "Project Chats" tab — same component and identical row affordances, scoped
+ * data supplied by the caller; the variant only changes the empty-state copy.
+ * The section title/count live in the shelf chip above, so the variant never
+ * renders a visible heading.
+ */
+export type RecentChatsVariant = "global" | "project";
+
interface GlobalRecentChatsSectionProps {
- /** Recent chats supplied by core (global getChatHistoryItems). */
+ /** Recent chats supplied by core (global or project-scoped getChatHistoryItems). */
items: ChatHistoryItem[];
+ /**
+ * Landing context — drives the empty-state copy only (the shelf owns the
+ * visible title). Defaults to `global`, preserving the original behavior.
+ */
+ variant?: RecentChatsVariant;
+ /**
+ * Human label for this section (e.g. "Recent Chats" / "Project Chats"). The
+ * shelf chip renders the visible title, so this is used only as the section's
+ * accessible label. Optional — omit to leave the group unlabeled.
+ */
+ title?: string;
/** Open a chat by id (markdown path or native session id). */
onLoadChat: (id: string) => Promise;
onUpdateTitle: (id: string, newTitle: string) => Promise;
@@ -25,6 +63,18 @@ interface GlobalRecentChatsSectionProps {
onOpenSourceFile: (id: string) => Promise;
/** Refresh the items (called once when the section mounts). */
onLoadHistory?: () => void;
+ /**
+ * Recent-list ids whose backend turn is running in the background. Matching
+ * rows swap their relative-time chip for a spinner. Omitted means "none".
+ */
+ runningChatIds?: ReadonlySet;
+ /**
+ * Recent-list ids whose live session is flagging needs-attention. OR'd with
+ * each item's baked-in `needsAttention` snapshot so the done-dot appears the
+ * moment a backgrounded turn finishes — the snapshot alone goes stale once
+ * the list is mounted. Omitted means "snapshot only".
+ */
+ attentionChatIds?: ReadonlySet;
className?: string;
}
@@ -53,16 +103,31 @@ function sortChatsByRecent(items: ChatHistoryItem[]): ChatHistoryItem[] {
/**
* Neutral tile holding the chat's backend brand glyph (or the generic
* fallback), sized to match the project tiles so the two shelf tabs share one
- * leading-slot width.
+ * leading-slot width. The attention dot mirrors the tab strip's cue for a
+ * backgrounded live session that finished / errored — without it the landing
+ * list would be the only chat surface hiding the signal (the conversation
+ * History popover renders it via the same wrapper).
*/
-const ChatIconTile = memo(({ Icon }: { Icon: React.ComponentType<{ className?: string }> }) => (
-
-
-
-));
+const ChatIconTile = memo(
+ ({
+ Icon,
+ needsAttention,
+ }: {
+ Icon: React.ComponentType<{ className?: string }>;
+ needsAttention?: boolean;
+ }) => (
+
+
+
+ )
+);
ChatIconTile.displayName = "ChatIconTile";
interface RecentChatRowProps {
@@ -81,6 +146,10 @@ interface RecentChatRowProps {
/** Whether this chat has a source note to open (markdown-saved only). */
canOpenSourceFile: boolean;
onOpenSourceFile: (id: string) => void;
+ /** Whether this chat's backend turn is running in the background. */
+ isRunning: boolean;
+ /** Snapshot ∪ live needs-attention — drives the icon tile's done-dot. */
+ hasAttention: boolean;
}
/**
@@ -104,13 +173,15 @@ const RecentChatRow = memo(function RecentChatRow({
onCancelDelete,
canOpenSourceFile,
onOpenSourceFile,
+ isRunning,
+ hasAttention,
}: RecentChatRowProps): React.ReactElement {
const Icon = resolveChatIcon(item) ?? MessageCircle;
if (isEditing) {
return (
-
+
onEditingTitleChange(e.target.value)}
@@ -152,7 +223,7 @@ const RecentChatRow = memo(function RecentChatRow({
}
}}
>
-
+
- {/* Relative time by default; the action cluster replaces it on hover or
+ {/* Relative time by default; a backgrounded running session shows an accent
+ spinner in its place. The action cluster replaces either on hover or
keyboard focus so a narrow sidebar doesn't have to fit both. The
`group-focus-within` path keeps the actions reachable for keyboard
users (focusing the row reveals them, so Tab can move into them) —
on hover alone they'd stay `display:none` and out of the tab order. */}
-
- {formatCompactRelativeTime(item.lastAccessedAt.getTime())}
-
+ {isRunning ? (
+
+ ) : (
+
+ {formatCompactRelativeTime(item.lastAccessedAt.getTime())}
+
+ )}
{confirmingDelete ? (
<>
@@ -247,19 +329,27 @@ const RecentChatRow = memo(function RecentChatRow({
});
/**
- * "Recent Chats" section for the Agent Home landing. A searchable, scrollable
- * list whose rows manage chats in place — open, rename, delete, and (for
- * markdown-saved chats only) open the source note — the same affordances as the
- * chat history popover, without a separate "view all" step. Native
- * (autosave-off) sessions appear here too; they just have no source note.
+ * "Recent Chats" section for the Agent Home landing. A searchable list whose
+ * rows manage chats in place — open, rename, delete, and (for markdown-saved
+ * chats only) open the source note — the same affordances as the chat history
+ * popover. The inline preview caps at {@link INLINE_LIMIT}; overflow lives
+ * behind a "View all chats" trigger that opens the full
+ * {@link ChatHistoryPopover}, while searching surfaces every match. Native
+ * (autosave-off) sessions appear here too; they just have no source note. The
+ * per-project landing reuses it (`variant="project"`) with scoped items and
+ * project empty copy — identical rows, no extra chrome.
*/
export const GlobalRecentChatsSection = memo(function GlobalRecentChatsSection({
items,
+ variant = "global",
+ title,
onLoadChat,
onUpdateTitle,
onDeleteChat,
onOpenSourceFile,
onLoadHistory,
+ runningChatIds,
+ attentionChatIds,
className,
}: GlobalRecentChatsSectionProps): React.ReactElement {
const [query, setQuery] = useState("");
@@ -273,12 +363,24 @@ export const GlobalRecentChatsSection = memo(function GlobalRecentChatsSection({
onLoadHistory?.();
}, [onLoadHistory]);
+ // Sort once for the inline preview (fixed recent-first, like the rest of the
+ // landing); the View-all popover re-sorts the full list by the user's
+ // configured chatHistorySortStrategy — the same management surface as the
+ // control bar's History button — so it reads raw `items` directly below.
const sortedItems = useMemo(() => sortChatsByRecent(items), [items]);
+ const isSearching = query.trim().length > 0;
const filteredItems = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return sortedItems;
return sortedItems.filter((item) => item.title.toLowerCase().includes(q));
}, [sortedItems, query]);
+ // Inline preview caps at INLINE_LIMIT; a search shows every match (the cap
+ // would hide exactly what the user is looking for).
+ const visibleItems = useMemo(
+ () => (isSearching ? filteredItems : filteredItems.slice(0, INLINE_LIMIT)),
+ [filteredItems, isSearching]
+ );
+ const hasOverflow = !isSearching && filteredItems.length > INLINE_LIMIT;
const handleStartEdit = useCallback((id: string, title: string) => {
setConfirmDeleteId(null);
@@ -309,41 +411,142 @@ export const GlobalRecentChatsSection = memo(function GlobalRecentChatsSection({
[onOpenSourceFile]
);
+ // Stable references so the memoized rows aren't all re-rendered on every
+ // section render (an inline arrow here would defeat RecentChatRow's memo).
+ const handleCancelEdit = useCallback(() => setEditingId(null), []);
+ const handleCancelDelete = useCallback(() => setConfirmDeleteId(null), []);
+
return (
-
+
{items.length > 0 && (
-
+ {/* Compact height so the search row reads as a list utility, not a
+ full-size form field towering over the 36px rows below. */}
+
)}
{filteredItems.length === 0 ? (
-
- {items.length === 0 ? "No recent chats" : "No matching chats"}
+
+ {items.length > 0
+ ? "No matching chats"
+ : variant === "project"
+ ? "No chats in this project yet"
+ : "No recent chats"}
-
+ // Outer divide-y separates the scroll region from the "View all" row
+ // below with the same hairline the rows use between themselves — the
+ // exact look of the Projects tab, where the trigger sits in the list's
+ // divide-y container.
+
+ {/* max-h-56 keeps a long search-result list scrolling INSIDE the card
+ near the inline preview's height, so searching doesn't balloon the
+ card. The inline (non-search) 5-row preview never reaches this cap,
+ and the "View all" row lives outside the scroll region so it can't
+ scroll out of reach. */}
+
+
+ {visibleItems.map((item) => (
+
+ ))}
+
+
+ {hasOverflow && (
+
+ {/* Trigger styled identically to the Projects tab's "View all
+ projects" row (AgentHomeViewAll) so the two shelf tabs read as
+ one component family.
+
+ DESIGN NOTE: unlike these inline rows, the popover's rows show
+ the open-source-note action for native (autosave-off) chats
+ too — pre-existing shared ChatHistoryPopover behavior (same
+ exposure as the control bar's History button; the click
+ degrades to a "no saved note" notice). Hiding it would mean a
+ per-row visibility prop on the shared popover — tracked as
+ shared-popover debt, not worth an entry-point hack here.
+
+ Radix merges its toggle onClick onto this child; Enter/Space
+ dispatch a click so the popover opens for keyboard users
+ without this row owning the popover's open state.
+
+ DESIGN NOTE: onLoadHistory runs on every toggle (open *and*
+ close), because Radix fires the merged onClick both ways and
+ this row can't see the popover's open state. That's an
+ intentional, harmless refresh — the same pattern the
+ conversation control bar uses on its History button
+ (AgentChatControls). loadChatHistory is mounted-guarded and
+ self-correcting, so a fast open/close race only momentarily
+ shows near-identical data. A "refresh only on open" fix would
+ need ChatHistoryPopover to expose onOpenChange — not worth
+ touching the shared base for this. */}
+
+ ))}
+
+ {/* Outputs: files generated during agent conversations. The producing
+ feature hasn't shipped yet, so this is the collapsed empty state the
+ design reserves — wired up once outputs land in the project folder. */}
+
+ );
+}
+
+interface ProjectInfoPopoverProps {
+ app: App;
+ project: ProjectConfig;
+ /** Live execution todo list of the active session (null = no Progress section). */
+ todoList: AgentTodoListEntry[] | null;
+ /** Fired after a successful edit via the gear (caller refreshes its cache). */
+ onEdited?: (project: ProjectConfig) => void;
+ /** Portal container — the AgentHome ROOT (the header sits outside the chat container). */
+ container?: HTMLElement | null;
+ className?: string;
+}
+
+/**
+ * Project-info popover anchored to the project header's trailing button,
+ * replacing the old `⋯` overflow menu (design: PROJECT_INFO_POPOVER.md,
+ * project-info-panel-hifi.html F1–F3). Top card (name + Edit gear + reveal) →
+ * Progress (live todo list, hidden when none) → Project files (System Prompt
+ * row + folder files + Outputs placeholder). Deliberately NO Delete and no
+ * config chips — deletion stays on the project list rows' inline actions.
+ */
+export const ProjectInfoPopover = memo(
+ ({ app, project, todoList, onEdited, container, className }: ProjectInfoPopoverProps) => {
+ const [open, setOpen] = useState(false);
+
+ const handleEdit = () => {
+ setOpen(false);
+ // Same agent-flavored edit the old overflow menu opened: full project
+ // modal minus the model card + CAG processing status.
+ new AddProjectModal(
+ app,
+ async (next) => {
+ try {
+ const updated = await ProjectFileManager.getInstance(app).updateProject(
+ project.id,
+ next
+ );
+ onEdited?.(updated.project);
+ } catch (e) {
+ // Reason: log for diagnostics, then rethrow so AddProjectModal keeps the
+ // form open and surfaces the failure (duplicate name / folder collision)
+ // instead of resolving onSave, closing, and discarding the user's edits.
+ // Mirrors the inline edit action in AgentProjectRowActions, which lets
+ // updateProject throw into the modal's own error handling.
+ logError("[ProjectInfoPopover] updateProject failed", e);
+ throw e;
+ }
+ },
+ project,
+ undefined,
+ true
+ ).open();
+ };
+
+ return (
+
+
+
+
+ e.preventDefault()}
+ >
+
+
+ {project.name}
+
+
+
+
+
+ setOpen(false)}
+ onEdited={onEdited}
+ />
+
+
+ );
+ }
+);
+
+ProjectInfoPopover.displayName = "ProjectInfoPopover";
diff --git a/src/agentMode/ui/ProjectPickerList.test.tsx b/src/agentMode/ui/ProjectPickerList.test.tsx
new file mode 100644
index 00000000..262a11e8
--- /dev/null
+++ b/src/agentMode/ui/ProjectPickerList.test.tsx
@@ -0,0 +1,104 @@
+// Mock the Manage modal so its transitive Obsidian-subclass imports
+// (FuzzySuggestModal via the row Edit action's AddProjectModal) don't crash
+// module load under the obsidian mock.
+jest.mock("@/components/modals/project/context-manage-modal", () => ({
+ ContextManageModal: jest.fn().mockImplementation(() => ({ open: jest.fn() })),
+}));
+
+import { ProjectConfig } from "@/aiParams";
+import { ProjectPickerList } from "@/agentMode/ui/ProjectPickerList";
+import { RecentUsageManager } from "@/utils/recentUsageManager";
+import { act, render } from "@testing-library/react";
+import { App } from "obsidian";
+import React from "react";
+
+// jsdom lacks Obsidian's `activeDocument`; the View-all popover portals into it.
+// The tests below keep to the inline list (≤ INLINE_LIMIT projects) and never
+// open that surface, but alias defensively.
+beforeAll(() => {
+ (window as unknown as { activeDocument: Document }).activeDocument = window.document;
+});
+
+const app = {} as App;
+const noop = () => {};
+
+function makeProject(
+ id: string,
+ usageTimestamps: number,
+ created = usageTimestamps
+): ProjectConfig {
+ return {
+ id,
+ name: id,
+ systemPrompt: "",
+ projectModelKey: "",
+ modelConfigs: {},
+ contextSource: {},
+ created,
+ UsageTimestamps: usageTimestamps,
+ };
+}
+
+/** Project-name order as rendered, top-to-bottom (ignores icon-only action buttons). */
+function renderedOrder(container: HTMLElement, names: string[]): string[] {
+ const rows = Array.from(container.querySelectorAll('[role="button"]'));
+ return rows
+ .map((row) => names.find((name) => row.textContent?.includes(name)))
+ .filter((name): name is string => Boolean(name));
+}
+
+describe("ProjectPickerList", () => {
+ const projectA = makeProject("A", 1000);
+ const projectB = makeProject("B", 2000);
+ const projectC = makeProject("C", 3000);
+ const names = ["A", "B", "C"];
+
+ function renderPicker(manager?: RecentUsageManager) {
+ return render(
+
+ );
+ }
+
+ it("renders projects in most-recently-used order from persisted timestamps", () => {
+ const { container } = renderPicker();
+ expect(renderedOrder(container, names)).toEqual(["C", "B", "A"]);
+ });
+
+ it("falls back to persisted order when no usage manager is provided", () => {
+ const { container } = renderPicker(undefined);
+ // No crash, and the persisted MRU order still holds.
+ expect(renderedOrder(container, names)).toEqual(["C", "B", "A"]);
+ });
+
+ it("re-sorts to reflect an in-memory touch ahead of the throttled persist", () => {
+ const manager = new RecentUsageManager();
+ const { container } = renderPicker(manager);
+ expect(renderedOrder(container, names)).toEqual(["C", "B", "A"]);
+
+ // Touch the oldest project in memory only (no persist). The revision
+ // subscription should re-sort it to the top even though its persisted
+ // UsageTimestamps is still the oldest.
+ act(() => {
+ manager.touch("A");
+ });
+
+ expect(renderedOrder(container, names)).toEqual(["A", "C", "B"]);
+ });
+
+ it("surfaces the inline Reveal / Edit / Delete actions on every row", () => {
+ const { getByLabelText } = renderPicker();
+ // The actions render inline per row (revealed on hover via CSS) instead of
+ // behind a single overflow trigger — getByLabelText throws if any is missing,
+ // so resolving all three per project is the assertion.
+ for (const name of names) {
+ expect(getByLabelText(`Reveal ${name} in vault`)).toBeTruthy();
+ expect(getByLabelText(`Edit project ${name}`)).toBeTruthy();
+ expect(getByLabelText(`Delete project ${name}`)).toBeTruthy();
+ }
+ });
+});
diff --git a/src/agentMode/ui/ProjectPickerList.tsx b/src/agentMode/ui/ProjectPickerList.tsx
index b305ce3a..a7d50fe1 100644
--- a/src/agentMode/ui/ProjectPickerList.tsx
+++ b/src/agentMode/ui/ProjectPickerList.tsx
@@ -4,138 +4,159 @@ import {
AgentHomeViewAll,
INLINE_LIMIT,
} from "@/agentMode/ui/AgentHomeSection";
+import { AgentProjectRowActions } from "@/agentMode/ui/AgentProjectRowActions";
+import { ProjectIconTile } from "@/agentMode/ui/ProjectIconTile";
import { ProjectConfig } from "@/aiParams";
+import { useRecentUsageManagerRevision } from "@/hooks/useRecentUsageManagerRevision";
import { cn } from "@/lib/utils";
import { filterProjects } from "@/utils/projectUtils";
-import { sortByStrategy, type SortStrategy } from "@/utils/recentUsageManager";
-import { Folder } from "lucide-react";
+import { RecentUsageManager, sortByStrategy, type SortStrategy } from "@/utils/recentUsageManager";
+import { App } from "obsidian";
import React, { memo, useMemo, useState } from "react";
-// Reason: PR1 is strictly read-only — surface a fixed sort (most-recently-used first)
-// without exposing a switcher or writing the strategy back to settings.
-const READ_ONLY_SORT_STRATEGY: SortStrategy = "recent";
-
-// Stable per-project accent: hash the id into one of six theme hues so a project
-// keeps the same color across renders and sessions (Math.random would reshuffle
-// every paint). Each entry pairs a solid `text` hue with its soft `bg` tint from
-// the `project` palette in tailwind.config — the folder glyph inherits the text
-// color, the tile shows the tint behind it.
-const PROJECT_ICON_PALETTE = [
- "tw-text-project-red tw-bg-project-red",
- "tw-text-project-orange tw-bg-project-orange",
- "tw-text-project-yellow tw-bg-project-yellow",
- "tw-text-project-green tw-bg-project-green",
- "tw-text-project-blue tw-bg-project-blue",
- "tw-text-project-purple tw-bg-project-purple",
-] as const;
-
-function projectIconClasses(id: string): string {
- let hash = 0;
- for (let i = 0; i < id.length; i++) {
- hash = (hash << 5) - hash + id.charCodeAt(i);
- hash |= 0; // clamp to 32-bit so long ids stay deterministic
- }
- return PROJECT_ICON_PALETTE[Math.abs(hash) % PROJECT_ICON_PALETTE.length];
-}
-
-/** Tinted square + colored folder, stable per project id. */
-const ProjectIconTile = memo(({ id }: { id: string }) => (
-
-
-
-));
-ProjectIconTile.displayName = "ProjectIconTile";
+// Reason: the landing surfaces a fixed most-recently-used order with no switcher
+// and never writes the strategy back to settings.
+const LANDING_SORT_STRATEGY: SortStrategy = "recent";
interface ProjectPickerListProps {
/** Full project list (already reactive from useProjects upstream). */
projects: ProjectConfig[];
- /** Caller-owned selection handler. In PR1 core wires this to a coming-soon Notice. */
+ /** Caller-owned selection handler. PR2a wires this to `enterProject`. */
onSelect: (project: ProjectConfig) => void;
/**
- * Optional header create action. When provided, a "+" button renders in the
- * section header. PR1 wires this to a coming-soon Notice (no project CRUD).
+ * Optional create action, rendered as the leading "New project" row. Receives
+ * the row's button element so the caller can anchor the create popover to it.
*/
- onCreate?: () => void;
+ onCreate?: (anchor: HTMLElement) => void;
+ /** Threaded to each row's inline actions (Reveal / Edit / Delete). */
+ app: App;
+ /** Forwarded to the row actions so the caller can exit a deleted active scope. */
+ onProjectDeleted?: (projectId: string) => void;
+ /**
+ * Shared in-memory usage manager. Blended into the sort + row time so entering a
+ * project reorders the list immediately, ahead of the throttled disk persist.
+ */
+ projectUsageTimestampsManager?: RecentUsageManager;
className?: string;
}
/**
- * Stable read-only ordering shared by the inline list and the View-all popover.
- *
- * DESIGN NOTE: sorts on the persisted `project.UsageTimestamps` only — it does
- * NOT blend in-memory touches via `RecentUsageManager.getEffectiveLastUsedAt`
- * the way the main chat-mode `ProjectList` does. This landing surface is
- * read-only: picking a project only fires a coming-soon Notice and never
- * touches usage, so the two lists diverge only in a narrow cross-surface race —
- * use a project in chat mode, then open Agent Home before the persisted
- * timestamp / file-backed project list catches up. Parity would mean threading
- * the project usage manager + a
- * `useSyncExternalStore` revision subscription into this deliberately minimal
- * read-only component; that cost outweighs the low-probability stale recency.
- * Accepted as consistency debt to resolve when the landing becomes interactive
- * (projects can be entered/touched here) by extracting a shared recency hook. If
- * a future review flags the divergence again, point them at this note.
+ * Effective last-used time for a project: the in-memory value (if more recent than
+ * the persisted one) so a just-entered project sorts/displays as most-recent before
+ * its timestamp persists, falling back to `created` when never used.
*/
-function useSortedProjects(projects: ProjectConfig[]): ProjectConfig[] {
+function effectiveLastUsedMs(
+ project: ProjectConfig,
+ manager: RecentUsageManager | undefined
+): number {
+ return (
+ manager?.getEffectiveLastUsedAt(project.id, project.UsageTimestamps) ||
+ project.UsageTimestamps ||
+ project.created
+ );
+}
+
+/**
+ * Most-recently-used ordering shared by the inline list and the View-all popover.
+ *
+ * The landing is interactive — entering a project touches its usage — so this blends
+ * the in-memory {@link RecentUsageManager} via `getEffectiveLastUsedAt` exactly like
+ * the chat-mode `ProjectList`, and both read the SAME shared manager instance. The
+ * revision subscription drives a re-sort when memory changes between throttled
+ * persists, so a just-entered project jumps to the top before its timestamp lands on
+ * disk.
+ */
+function useSortedProjects(
+ projects: ProjectConfig[],
+ manager: RecentUsageManager | undefined
+): ProjectConfig[] {
+ const revision = useRecentUsageManagerRevision(manager);
return useMemo(
() =>
- sortByStrategy(projects, READ_ONLY_SORT_STRATEGY, {
+ sortByStrategy(projects, LANDING_SORT_STRATEGY, {
getName: (project) => project.name,
getCreatedAtMs: (project) => project.created,
- getLastUsedAtMs: (project) => project.UsageTimestamps,
+ getLastUsedAtMs: (project) =>
+ manager?.getEffectiveLastUsedAt(project.id, project.UsageTimestamps) ??
+ project.UsageTimestamps,
}),
- [projects]
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- revision triggers re-sort when the manager's in-memory state changes
+ [projects, manager, revision]
);
}
interface ProjectRowProps {
project: ProjectConfig;
+ /**
+ * Effective last-used time, computed by the parent. Passed as a prop (not read
+ * from `project` here) so this `memo`'d row re-renders when only the in-memory
+ * time changes — the project reference itself stays stable across a touch.
+ */
+ timeMs: number;
onSelect: (project: ProjectConfig) => void;
+ app: App;
+ onDeleted?: (projectId: string) => void;
}
-const ProjectRow = memo(({ project, onSelect }: ProjectRowProps) => (
+const ProjectRow = memo(({ project, timeMs, onSelect, app, onDeleted }: ProjectRowProps) => (
onSelect(project)}
leading={}
+ trailing={}
/>
));
ProjectRow.displayName = "ProjectRow";
/**
- * Read-only project browser for the Agent Home landing (design A.2 + B.2).
+ * Project browser for the Agent Home landing (design A.2 + B.2).
*
* Shows the {@link INLINE_LIMIT} most-recent projects inline with a "View all
* projects" affordance opening an in-pane search popover (not a fullscreen
* modal). Selection and the optional create action are delegated to the caller;
- * this component never mutates project state, usage, or current-project.
+ * this component never mutates project state directly. Entering a project (via the
+ * caller) touches usage on the shared manager, which reorders this list live.
*/
export const ProjectPickerList = memo(
- ({ projects, onSelect, onCreate, className }: ProjectPickerListProps): React.ReactElement => {
+ ({
+ projects,
+ onSelect,
+ onCreate,
+ app,
+ onProjectDeleted,
+ projectUsageTimestampsManager,
+ className,
+ }: ProjectPickerListProps): React.ReactElement => {
const [searchQuery, setSearchQuery] = useState("");
- const sortedProjects = useSortedProjects(projects);
+ const sortedProjects = useSortedProjects(projects, projectUsageTimestampsManager);
const inlineProjects = useMemo(() => sortedProjects.slice(0, INLINE_LIMIT), [sortedProjects]);
const total = projects.length;
const hasOverflow = total > INLINE_LIMIT;
return (
-
+ // tw-grow fills the shelf panel's fixed floor (AgentHomeShelf) so the
+ // empty-state copy below can center inside the card; the "New project"
+ // action row stays pinned at the top either way.
+