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. */} +
+
+ + ) : undefined + } + /> +
+
+ {createAnchor && ( + setCreateAnchor(null)} + onSave={persistCreateProject} + /> + )}
{isDragActive && ( -
+ // 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). */}
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 : + + {orphaned ? ( + + This project no longer exists + + ) : ( + <> + + + {projectName} + + + {menu} + + )} +
+ ) +); + +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 -
  • - - {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 +
  • + + {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"}
) : ( - -
- {filteredItems.map((item) => ( - setEditingId(null)} - onStartDelete={setConfirmDeleteId} - onConfirmDelete={handleConfirmDelete} - onCancelDelete={() => setConfirmDeleteId(null)} - canOpenSourceFile={!isNativeChatId(item.id)} - onOpenSourceFile={handleOpenSourceFile} - /> - ))} -
-
+ // 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. */} +
onLoadHistory?.()} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.currentTarget.click(); + } + }} + > + View all chats + +
+
+ )} +
)}
); diff --git a/src/agentMode/ui/ProjectIconTile.tsx b/src/agentMode/ui/ProjectIconTile.tsx new file mode 100644 index 00000000..1f0e3bb1 --- /dev/null +++ b/src/agentMode/ui/ProjectIconTile.tsx @@ -0,0 +1,52 @@ +import { cn } from "@/lib/utils"; +import { Folder } from "lucide-react"; +import React, { memo } from "react"; + +// 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]; +} + +interface ProjectIconTileProps { + /** Project id — the sole color input, so the accent survives renames. */ + id: string; +} + +/** + * Tinted square + colored folder, stable per project id. The project's visual + * identity primitive, shared by every surface that represents a project (the + * Projects-tab rows, the in-project workspace header) so one project always + * carries one color. Decorative: the adjacent project name is the readable text. + */ +export const ProjectIconTile = memo( + ({ id }: ProjectIconTileProps): React.ReactElement => ( + + ) +); +ProjectIconTile.displayName = "ProjectIconTile"; diff --git a/src/agentMode/ui/ProjectInfoPopover.test.tsx b/src/agentMode/ui/ProjectInfoPopover.test.tsx new file mode 100644 index 00000000..f764aaf7 --- /dev/null +++ b/src/agentMode/ui/ProjectInfoPopover.test.tsx @@ -0,0 +1,166 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; + +// activeDocument global (Radix popover portals into it). +beforeAll(() => { + (window as unknown as { activeDocument: Document }).activeDocument = window.document; +}); + +const updateProject = jest.fn().mockResolvedValue({ project: {} }); +jest.mock("@/projects/ProjectFileManager", () => ({ + ProjectFileManager: { getInstance: () => ({ updateProject }) }, +})); + +const getCachedProjectRecordById = jest.fn(); +jest.mock("@/projects/state", () => ({ + getCachedProjectRecordById: (...args: unknown[]) => getCachedProjectRecordById(...args), +})); + +jest.mock("@/projects/projectPaths", () => ({ + getProjectFolderPath: (folderName: string) => `copilot/projects/${folderName}`, +})); + +// Keep the edit/reveal collaborators inert — exercised elsewhere. +jest.mock("@/components/modals/project/AddProjectModal", () => ({ + AddProjectModal: jest.fn().mockImplementation(() => ({ open: jest.fn() })), +})); +const revealProjectFolder = jest.fn(); +jest.mock("@/agentMode/ui/AgentProjectRowActions", () => ({ + revealProjectFolder: (...args: unknown[]) => revealProjectFolder(...args), +})); +const openSystemPromptModal = jest.fn(); +jest.mock("@/agentMode/ui/ProjectSystemPromptModal", () => ({ + ProjectSystemPromptModal: jest.fn().mockImplementation((...args: unknown[]) => { + openSystemPromptModal(...args); + return { open: jest.fn() }; + }), +})); + +import { TFile, TFolder } from "obsidian"; +import { ProjectInfoPopover } from "@/agentMode/ui/ProjectInfoPopover"; +import type { AgentTodoListEntry } from "@/agentMode/session/types"; +import type { ProjectConfig } from "@/aiParams"; + +const PROJECT = { + id: "proj-1", + name: "My Research", + systemPrompt: "be helpful", + contextSource: {}, +} as unknown as ProjectConfig; + +function makeFolder(names: string[]) { + const folder = new (TFolder as unknown as new (path: string) => TFolder)( + "copilot/projects/proj-1" + ); + (folder as unknown as { children: unknown[] }).children = names.map( + (n) => new (TFile as unknown as new (path: string) => TFile)(`copilot/projects/proj-1/${n}`) + ); + return folder; +} + +// A marker line a generated AGENTS.md mirror carries (see ensureAgentsMirror.ts) — a read +// returning this means "generated, hide it". Ownership keys off the stable +// `MIRROR_MARKER_PREFIX`, not the exact wording, so this older-tail variant is still detected; +// keeping it here doubles as a backward tail-compat check. +const MIRROR_MARKER = + ""; + +function renderPopover( + todoList: AgentTodoListEntry[] | null, + folderNames: string[] = [], + fileContents: Record = {} +) { + getCachedProjectRecordById.mockReturnValue({ folderName: "proj-1" }); + const openFile = jest.fn().mockResolvedValue(undefined); + const app = { + vault: { + getAbstractFileByPath: jest.fn().mockReturnValue(makeFolder(folderNames)), + read: jest.fn((file: TFile) => Promise.resolve(fileContents[file.name] ?? "")), + }, + workspace: { getLeaf: jest.fn().mockReturnValue({ openFile }) }, + } as unknown as Parameters[0]["app"]; + render(); + // Open the popover. + fireEvent.click(screen.getByLabelText("Project info for My Research")); + return { openFile }; +} + +describe("ProjectInfoPopover", () => { + beforeEach(() => jest.clearAllMocks()); + + it("renders the project name and the System Prompt row (never the backing file)", async () => { + renderPopover(null, ["project.md", "AGENTS.md", "notes.md"]); + expect(screen.getAllByText("My Research").length).toBeGreaterThan(0); + // Await the async file listing so its setState settles inside act(). + expect(await screen.findByText("System Prompt")).toBeTruthy(); + }); + + it("omits the Progress section when there is no todo list", async () => { + renderPopover(null); + expect(await screen.findByText("System Prompt")).toBeTruthy(); + expect(screen.queryByText("Progress")).toBeNull(); + }); + + it("renders the todo list with a completed/total count when present", async () => { + renderPopover([ + { content: "step A", status: "completed" }, + { content: "step B", status: "in_progress" }, + { content: "step C", status: "pending" }, + ]); + await screen.findByText("System Prompt"); + expect(screen.getByText("Progress")).toBeTruthy(); + expect(screen.getByText("1/3")).toBeTruthy(); + expect(screen.getByText("step A")).toBeTruthy(); + expect(screen.getByText("step C")).toBeTruthy(); + }); + + it("lists folder files but excludes project.md and a GENERATED AGENTS.md mirror", async () => { + renderPopover(null, ["project.md", "AGENTS.md", "guide.pdf", "draft.md"], { + "AGENTS.md": `${MIRROR_MARKER}\n\nbe helpful`, // marker → generated mirror + }); + expect(await screen.findByText("guide.pdf")).toBeTruthy(); + expect(screen.getByText("draft.md")).toBeTruthy(); + expect(screen.queryByText("project.md")).toBeNull(); + expect(screen.queryByText("AGENTS.md")).toBeNull(); + }); + + it("KEEPS a user-authored AGENTS.md (no marker) in the file list", async () => { + renderPopover(null, ["project.md", "AGENTS.md", "draft.md"], { + "AGENTS.md": "my own agent rules", // no marker → user-authored, must show + }); + expect(await screen.findByText("AGENTS.md")).toBeTruthy(); + expect(screen.getByText("draft.md")).toBeTruthy(); + expect(screen.queryByText("project.md")).toBeNull(); + }); + + it("keeps an AGENTS.md visible when its content cannot be read", async () => { + getCachedProjectRecordById.mockReturnValue({ folderName: "proj-1" }); + const app = { + vault: { + getAbstractFileByPath: jest.fn().mockReturnValue(makeFolder(["AGENTS.md", "draft.md"])), + read: jest.fn().mockRejectedValue(new Error("read failed")), + }, + workspace: { getLeaf: jest.fn().mockReturnValue({ openFile: jest.fn() }) }, + } as unknown as Parameters[0]["app"]; + render(); + fireEvent.click(screen.getByLabelText("Project info for My Research")); + // Read failure must not hide a possibly-user file. + expect(await screen.findByText("AGENTS.md")).toBeTruthy(); + }); + + it("opens the System Prompt editor when the row is clicked", async () => { + renderPopover(null); + fireEvent.click(await screen.findByText("System Prompt")); + expect(openSystemPromptModal).toHaveBeenCalledTimes(1); + // (app, initialPrompt, persistFn) + expect(openSystemPromptModal.mock.calls[0][1]).toBe("be helpful"); + }); + + it("reveals the project folder from the header button", async () => { + renderPopover(null); + await screen.findByText("System Prompt"); + fireEvent.click(screen.getByLabelText("Reveal project folder in vault")); + expect(revealProjectFolder).toHaveBeenCalledWith(expect.anything(), PROJECT); + }); +}); diff --git a/src/agentMode/ui/ProjectInfoPopover.tsx b/src/agentMode/ui/ProjectInfoPopover.tsx new file mode 100644 index 00000000..6b1eaff8 --- /dev/null +++ b/src/agentMode/ui/ProjectInfoPopover.tsx @@ -0,0 +1,385 @@ +import { revealProjectFolder } from "@/agentMode/ui/AgentProjectRowActions"; +import { ProjectSystemPromptModal } from "@/agentMode/ui/ProjectSystemPromptModal"; +import type { AgentTodoListEntry } from "@/agentMode/session/types"; +import { ProjectConfig } from "@/aiParams"; +import { AddProjectModal } from "@/components/modals/project/AddProjectModal"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { logError } from "@/logger"; +import { isGeneratedAgentsMirrorContent } from "@/projects/ensureAgentsMirror"; +import { getProjectFolderPath } from "@/projects/projectPaths"; +import { ProjectFileManager } from "@/projects/ProjectFileManager"; +import { getCachedProjectRecordById } from "@/projects/state"; +import { + Check, + ChevronDown, + ChevronRight, + FolderSearch, + List, + Settings, + SquarePen, +} from "lucide-react"; +import { App, TFile, TFolder } from "obsidian"; +import React, { memo, useEffect, useState } from "react"; + +/** + * The project's instruction config — always represented by the fixed "System + * Prompt" row, never listed as a plain file. `AGENTS.md` is NOT in here: it can + * be either the plugin's generated mirror (hidden) or a user-authored file + * (shown), distinguished by marker content, not by name — see the listing. + */ +const HIDDEN_BASENAME = "project.md"; +const AGENTS_BASENAME = "agents.md"; + +/** Per-extension badge tints, the same project palette tokens rows use. */ +const BADGE_CLASSES: Record = { + pdf: "tw-bg-project-red tw-text-project-red", + md: "tw-bg-project-blue tw-text-project-blue", + html: "tw-bg-project-orange tw-text-project-orange", + png: "tw-bg-project-green tw-text-project-green", +}; + +function FileBadge({ ext }: { ext: string }) { + return ( + + ); +} + +interface ProgressSectionProps { + todoList: AgentTodoListEntry[] | null; +} + +/** + * The agent's live execution todo list (`getCurrentTodoList`). Per the design + * (and matching the backends' own behavior), NO active list renders nothing — + * the section never placeholder-pads the popover. + */ +function ProgressSection({ todoList }: ProgressSectionProps) { + if (!todoList || todoList.length === 0) return null; + const done = todoList.filter((t) => t.status === "completed").length; + return ( +
+
+ Progress + + {done}/{todoList.length} + +
+
    + {todoList.map((todo, i) => ( + // eslint-disable-next-line @eslint-react/no-array-index-key -- entries are positional and may share content +
  • + + + {todo.content} + +
  • + ))} +
+
+ ); +} + +interface ProjectFilesSectionProps { + app: App; + project: ProjectConfig; + onClose: () => void; + /** Fired after the System Prompt is saved, so the caller refreshes its cache. */ + onEdited?: (project: ProjectConfig) => void; +} + +function ProjectFilesSection({ app, project, onClose, onEdited }: ProjectFilesSectionProps) { + const [outputsOpen, setOutputsOpen] = useState(false); + const [files, setFiles] = useState([]); + + // Direct children of the project folder, minus: ALL dot-prefixed entries + // (user dot-files like `.env` — hiding dot-files is the conventional listing + // default, matching Finder/`ls` + // and Obsidian's own dot-folder handling), project.md (the System Prompt row + // represents it), and a GENERATED AGENTS.md mirror. A user-authored AGENTS.md + // (no marker) is kept — distinguishing the two needs the file CONTENT, not + // its name, so the listing is async. Runs when the popover body mounts (it + // only exists while open, so each open re-lists fresh without a vault + // subscription). + useEffect(() => { + let cancelled = false; + void (async () => { + const record = getCachedProjectRecordById(project.id); + const folderPath = record ? getProjectFolderPath(record.folderName) : null; + const folder = folderPath ? app.vault.getAbstractFileByPath(folderPath) : null; + if (!(folder instanceof TFolder)) { + if (!cancelled) setFiles([]); + return; + } + const candidates = folder.children.filter( + (child): child is TFile => + child instanceof TFile && + !child.name.startsWith(".") && + child.name.toLowerCase() !== HIDDEN_BASENAME + ); + const visible: TFile[] = []; + for (const child of candidates) { + if (child.name.toLowerCase() === AGENTS_BASENAME) { + // Hide only the generated mirror; on read failure keep the file + // visible rather than risk hiding a user's own AGENTS.md. + const isMirror = await app.vault + .read(child) + .then(isGeneratedAgentsMirrorContent) + .catch(() => false); + if (isMirror) continue; + } + visible.push(child); + } + visible.sort((a, b) => a.name.localeCompare(b.name)); + if (!cancelled) setFiles(visible); + })(); + return () => { + cancelled = true; + }; + }, [app, project.id]); + + const handleOpenFile = (file: TFile) => { + onClose(); + void app.workspace + .getLeaf(false) + .openFile(file) + .catch((err) => logError("[ProjectInfoPopover] openFile failed", err)); + }; + + const handleEditSystemPrompt = () => { + onClose(); + new ProjectSystemPromptModal(app, project.systemPrompt ?? "", async (prompt) => { + const updated = await ProjectFileManager.getInstance(app).updateProject(project.id, { + ...project, + systemPrompt: prompt, + }); + // Keep the caller's cached project in sync with the gear-edit path, so a + // reopen reads the new prompt instead of the pre-save value. + onEdited?.(updated.project); + }).open(); + }; + + return ( +
+
+ Project files + +
+ + {/* Fixed first row: the project's instructions. Shows ONLY the label — + never the backing file name (project.md / the AGENTS.md mirror). */} +
{ + if (e.key === "Enter" || e.key === " ") handleEditSystemPrompt(); + }} + > + + System Prompt +
+ + {files.map((file) => ( +
handleOpenFile(file)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") handleOpenFile(file); + }} + > + + + {file.name} + +
+ ))} + + {/* 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. */} +
setOutputsOpen((v) => !v)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") setOutputsOpen((v) => !v); + }} + aria-expanded={outputsOpen} + > + {outputsOpen ? ( +
+ {outputsOpen && ( +
No outputs yet
+ )} +
+ ); +} + +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. +
{onCreate && } {total === 0 ? ( -
No projects available
+
+ No projects available +
) : ( <> {inlineProjects.map((project) => ( - + ))} {hasOverflow && ( { onSelect(selected); close(); }} + app={app} + onDeleted={onProjectDeleted} /> )} /> diff --git a/src/agentMode/ui/ProjectSystemPromptModal.tsx b/src/agentMode/ui/ProjectSystemPromptModal.tsx new file mode 100644 index 00000000..e899dbe5 --- /dev/null +++ b/src/agentMode/ui/ProjectSystemPromptModal.tsx @@ -0,0 +1,106 @@ +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { logError } from "@/logger"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; +import { App, Modal, Notice } from "obsidian"; +import React, { useState } from "react"; +import { Root } from "react-dom/client"; + +interface ProjectSystemPromptModalContentProps { + initialPrompt: string; + onSave: (prompt: string) => Promise; + onCancel: () => void; +} + +function ProjectSystemPromptModalContent({ + initialPrompt, + onSave, + onCancel, +}: ProjectSystemPromptModalContentProps) { + const [prompt, setPrompt] = useState(initialPrompt); + const [saving, setSaving] = useState(false); + + const handleSave = async () => { + setSaving(true); + try { + await onSave(prompt); + } finally { + setSaving(false); + } + }; + + return ( +
+