mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
feat(agent-projects): project-scoped Agent Mode — Agent Home PR2 (#2604)
* feat(agent-mode): project-scoped Agent Mode workspaces (PR2)
Turn 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
(GLOBAL_SCOPE sentinel) sharing the same code path.
Session scope
- AgentSessionManager stores sessions by internal id but binds each to an
immutable projectId; per-scope MRU, resolveSessionCwd, getSessionsForScope, and
enterProject/exitProject all key off that projectId, and the active-session
invariant (active.projectId === activeProjectId) keeps existing UI helpers
untouched.
- Backend restarts / in-place replacement inherit the replaced session's
projectId; project scope reuses the warm process instead of adopting the
vault-root probe session.
- Re-entering a project opens as a fresh visit: conversational chats detach from
the tab strip (kept live in the pool, listed in chat history) 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.
Context materialization (off-vault shared cache)
- projectContextMaterializer + contextCacheStore + manifestBuilder capture a
project's URL/YouTube/PDF context once per session (single-flight per project)
into a per-vault, off-vault store shared across projects
(~/.obsidian-copilot/vaults/<vaultId>/context-cache/): a source referenced by N
projects is converted once per vault, not N times. Paths derive from one source
of truth (conversionsLocation.ts); getVaultId lives in utils/appPaths.
- Layout: shared remotes/ + files/ snapshots keyed by source identity (md5),
per-project markers/<md5(projectId)>/ failure markers. A root-confined node:fs
backend (createNodeContextCacheFs) writes atomically (temp+rename), throws on
write/mkdir/read while list/remove/clear tolerate a missing target (the store
treats a failed read as a cache miss), and never ascends past the cache root; it
loads node:fs lazily behind the desktop boundary so Obsidian mobile never pulls
it into the bundle.
- A per-artifact async-mutex lock makes two projects cold-converting the same
source converge to a single fetch (the waiter re-reads meta inside the lock and
cheap-skips). Shared snapshots are never reconciled per-project; a project's
stale marker is cleared on a cheap-skip hit or when a usable stale snapshot
survives a failed refresh.
- Cache semantics match the legacy CAG cache: a successful snapshot is kept
indefinitely (fingerprint cheap-skip, no TTL); a failure with no usable
snapshot writes a marker and is cheap-skipped on later automatic runs
(re-surfacing the error) until the file changes or the user retries. A
schemaVersion stamped into every snapshot/marker makes a future format change
re-materialize instead of misreading stale content; MATERIALIZED_SOURCE_TYPES
is the single source of truth for source kinds.
- The first prompt inlines a <project_context> block listing absolute snapshot
paths (keyed by type:source so a same-URL web+youtube pair resolves correctly)
so all three backends reach the shared store; opencode gets an
external_directory allow for the cache root. Per-source status mirrors CAG's
ProjectLoadTracker into the projectId-keyed agentProjectContextLoadAtom
(remotes parallel, files sequential; each flips Ready/Failed as it settles) and
the composer shows a context status icon and queues sends while materializing.
The never-reject contract and contextSignature/warm reactivity are preserved;
released CAG caches (.copilot/*) are untouched.
Instructions mirror
- project.md stays the single source of truth, with a marker-gated AGENTS.md
mirror generated by ensureAgentsMirror: codex/opencode auto-discover it from
the session cwd, claude gets the same composed instructions via
getProjectProfile. A built-in project policy is layered into each project's
instructions (a user-authored, unmarked AGENTS.md is left untouched).
Landing & project UI
- AgentLandingStack spine with project landing state, header, Welcome card,
Project Chats tab variant, and project CRUD (anchored create popover, edit
modal, overflow menu); shelf lists cap at 5 behind a View-all popover.
- One context editor (ProjectContextSourceEditor) is shared by the home shelf and
the Edit Project modal; the Manage modal leads with a Links section whose single
add affordance is the sidebar "+URL" popover (the old right-pane inline URL
input is removed for consistency with Tags/Folders/Files). A three-state
(processing / ready / failed) status popover offers inline per-source retry, its
landing refresh deferred to popover-close and scope-guarded. The web/youtube
classification is consolidated into one UrlKind type reused across every URL
surface. Truncated previews mark the cut with a labelled divider and a "Copy
full content" pill, and cap render so large snapshots don't freeze the modal.
History scope
- Agent chat history is scope-keyed: a project view lists only its own chats,
while GLOBAL_SCOPE is the flat all-chats view. Native session history is scoped
to projects and resumed transcripts hydrate from the session's scope cwd; legacy
chats with no projectId resolve to GLOBAL_SCOPE. A failed resume/create during a
cross-scope history load rolls back the active scope (rollbackHistoryLoadScope)
so the active-session invariant can't be left split.
Todo / plan
- Unified backend todo lists across claude/codex/opencode feed the project info
popover, with per-session todo-id tracking and symmetric plan clear.
Hardening & fixes
- contextCacheFs rejects `..`, absolute, and root-escaping paths; symlink escape
is documented as out of the cache's threat model.
- A hard-disabled composer (orphaned project) blocks keyboard sends and queued
flushes, not just pointer events, so a turn can't drain into a dead project.
- The per-message attachment envelope is renamed <copilot-context> ->
<attached_context> to disambiguate from the project-wide <project_context>.
Tests
- 30+ suites covering session scope, the off-vault cache store/fs (including a
real-filesystem dedup integration test), the materializer, AGENTS.md mirror,
todo-plan pipeline, landing UI, the context-signature refresh state machine, and
the opencode external_directory allow; plus a mobile load-boundary smoke guard
over the cache consumers.
* fix(agent-mode): keep project edit modal open on save failure
ProjectInfoPopover's edit onSave swallowed updateProject errors into a
Notice, so onSave resolved and AddProjectModal closed — discarding the
user's edits on duplicate-name / folder-collision failures. Log and
rethrow instead so the modal keeps the form open and surfaces the error,
matching AgentProjectRowActions.handleEdit.
* fix(agent-mode): preserve project context after first-turn fan-out; clear off-vault markers on delete
A first-turn multi-agent fan-out set firstPromptSent while only the
ephemeral sub-sessions received the <project_context> block — the visible
backend is never prompted on that path. The next normal turn then injected
null, permanently stripping the project manifest from the main chat. Stop
the fan-out branch from consuming the first-turn flag so the next visible
turn delivers it. Adds a regression test.
Also clear the off-vault failure-marker bucket (markers/<md5(projectId)>)
in deleteProject so a project recreated under the same id can't inherit
stale negative-cache state. Desktop-gated + dynamically imported; the
helper roots a confined fs at the bucket and reuses recursive clear().
* fix(agent-mode): refresh empty project landing on System Prompt edit
An empty landing session bakes in its project instructions at creation
(Claude via systemPromptAppend, codex/opencode via the AGENTS.md mirror),
but the landing-refresh trigger keyed on the materialization signature,
which deliberately omits systemPrompt. A System-Prompt-only edit therefore
left the open landing untouched, so the first message ran with stale
instructions until a manual new chat.
Add getProjectLandingCaptureSignature (materialization signature + the
instruction body) and key the landing-refresh observer on it, leaving
getProjectContextSignature untouched so prompt edits never trigger
re-materialization. Adds tests.
* fix(agent-mode): preserve compose draft typed during landing refresh
The empty-landing context refresh replaces the session in place; its
draftEmpty guard ran before the async backend-startup window, so text the
user typed during that window landed in the old session's draft and was
pruned when the old session closed — breaking the guard's stated 'never
discard text the user has started typing' contract.
Add useAgentInputDrafts.migrateDraft and call it right after the swap to
carry any draft typed during startup onto the new session id. Writes
setDrafts directly to bypass the live-id guard; ordering is safe because
closeSession awaits cancel() before deleting the old session, so the
source draft is still present when migration runs. Adds tests.
* fix(agent-mode): don't reuse a project landing that captured stale instructions
The previous fix made the active landing refresh on a System-Prompt edit,
but enterProject's session-reuse path gated only on the materialization
dirty flag, which deliberately ignores systemPrompt. So editing a
non-active project's System Prompt then re-entering reused the old landing
and ran the next turn with stale captured instructions.
Track a per-session landing-capture signature (materialization signature +
instruction body) at creation and require a reuse candidate to match the
live project config. When spawning fresh, detach existing empty landings so
a stale blank one can't linger. Leaves the materialization dirty machinery
untouched (it must stay systemPrompt-insensitive). Adds a regression test.
* docs(agent-mode): note PR2 tag/extension routing and Tier-1 exclusion scope
Two DESIGN NOTEs at the project context materializer, recording decisions
already settled in PR2_DESIGN.md so future reviews don't re-flag them:
- tag/extension inclusions are forwarded as source labels and reached via the
agent's native search; precise resolution (MCP tag_search) is a PR3 item.
- exclusions are Tier 1 (best-effort: don't materialize / don't feed); they do
not hard-block the agent's native grep — that needs a Tier 2/3 sandbox,
deferred past PR2.
* fix(agent-mode): let project sessions read opted-in off-vault context
The built-in project prompt's default-only-cwd rule buried its carve-out in
a negative clause, so agents read an off-vault materialized snapshot path
(~/.obsidian-copilot/.../context-cache/...) as a forbidden external file and
declined to read it — even though the project itself configured that source.
Restructure the built-in prompt by axis (write / read+exception / boundary)
and name the <project_context> block explicitly: sources listed there are
opt-in to read and search even outside cwd, and a -> <abs path> snapshot
pointer is read directly. Pin the cross-file <project_context> tag coupling
with a regression test that diffs the prompt against the manifest's real
output.
* refactor(agent-mode): unify agent context status into one shared view
Per-item conversion status (icon + label + lookup key) was duplicated across
three agent surfaces: the composer status popover, the Manage modal's Links
panel, and — newly needed — its File Context file list. Each had its own
status->icon mapping, and the file list had none at all (it was wired to the
CAG load atom, which the agent pipeline never populates, so agent files showed
no status while URLs did).
Introduce processingItemStatusView as the single source of truth
(processingSourceKey / buildProcessingItemLookup / getProcessingStatusLabel /
ProcessingStatusIcon) and route all three surfaces through it. The Manage modal
now builds one agent status lookup (gated to the agent variant) shared by both
the Links panel and the file list, so file rows finally show conversion status
keyed by file:<path>. The legacy CAG status stays separate (different cache,
different semantics). Adds useAgentProcessingItems({ enabled }) so CAG/mobile
callers skip the off-vault read entirely.
* feat(agent-mode): show conversion status + snapshot preview for project files
The context editor's File Context list showed no conversion status or preview
for agent project files, while the Links panel showed both for URLs — an
asymmetry, since file snapshots also live in the off-vault cache. Wire file
rows to the same agent status pipeline: a ready file now shows its status icon
and a 'view parsed content' arrow that opens the off-vault snapshot via
openAgentCachedItemPreview (the same opener URLs use). Markdown has no snapshot
so it shows neither; the CAG path is unchanged.
Collapse the per-prop enableLinks branching at the row into one
getFileRowStatusProps resolver (agent vs CAG dispatched once). Also corrects
stale comments flagged in review (STATUS_COLOR is CAG-badge-only; the agent
hook still subscribes to its atom but the value is unused when disabled).
* refactor(agent-mode): neutralize reviewer-dialogue comments; share source builder
Drop the "if a future review flags this, point them here" reviewer-meta
sentences from this branch's own comments (keeping all why/invariant
rationale), leaving the pre-existing base-convention instances untouched.
Extract buildAgentProcessingSources() so useAgentProcessingItems and
useAgentPersistentFailureCount construct the AgentProcessingSource[] shape
through one shared pure helper instead of two copies that could drift; each
hook still passes its own candidate set (draft vs saved), so behavior is
unchanged.
* fix(agent-mode): restore the <copilot-context> attachment envelope
The per-message attachment envelope was renamed <copilot-context> →
<attached_context> in an earlier feature commit (8b880361), bundled as an
incidental "disambiguate from <project_context>" tweak. That changed AI prompt
content emitted to EVERY agent chat (not just project-scoped ones) without an
explicit request, against the repo rule "Never modify AI prompt content unless
the user explicitly asks"; base v4-preview has used <copilot-context> since
Agent Mode was introduced. Revert the tag (the inner instruction text was
unchanged) so the established prompt contract is preserved. The two
claudeSessionTranscript tests already expected <copilot-context>, so this also
restores prod/test consistency.
* fix(agent-mode): supersede a stale in-flight context materialization on edit
The per-project single-flight let any non-force caller join the in-flight run
unconditionally. If a project's context was edited while an earlier
materialization was still running (slow URL/PDF conversion + a queued first
prompt), the next session joined the stale run and received the pre-edit
<project_context> / additionalDirectories — the queued send could flush with
the old source set, and the new links/files were only captured on a later chat.
Carry the run's context signature in the in-flight entry and join only when it
matches the caller's current record signature; a differing signature supersedes
via the existing take-over-slot path (which defers disk work until the prior run
settles, so no cache-file race). Regression test added to the single-flight
describe.
* fix(agent-mode): roll back project scope when auto-spawn fails
enterProject optimistically parks the prior scope, points activeProjectId
at the new project, detaches its tabs, and nulls activeSessionId before
awaiting the auto-spawn of the scope's first session. When that spawn
rejects (e.g. a missing backend binary), the callers only surface a
Notice, leaving the manager scoped to a project with no active chat until
the user manually recovers.
Wrap the project-scope auto-spawn in spawnEnteredScopeOrRollback, which
restores the previous scope's activeProjectId + active-session pointer and
re-notifies on failure, then rethrows so the caller still reports it. The
rollback is guarded on still being parked in the attempted scope so a
concurrent enterProject isn't clobbered — mirroring the existing
rollbackHistoryLoadScope precedent for the history-load path.
* fix(agent-mode): address review findings (URL trim perf, snapshot meta parse, docs)
Three PR-introduced fixes from the PR2 review plus a deferral note:
- urlTagUtils: rewrite trimUrlTrailingPunctuation from O(n²) (a full split
scan per stripped char) to O(n) — pre-count brackets once, then walk the end
pointer left. A pathological trailing-bracket paste no longer freezes the main
thread. Semantics are unchanged (balanced brackets kept, unbalanced stripped,
hidden punctuation re-trimmed). Adds a 100k perf tripwire test.
- contextCacheStore: anchor parseSnapshotMeta's close marker to a line boundary
(\n--> ) instead of the first --> anywhere, which a source path containing -->
would match inside the embedded JSON, truncating it and forcing a permanent
cache miss. Adds a regression test.
- projects/state: correct doc comments — project.md is the SSOT, AGENTS.md is
its generated mirror (was inverted).
- AgentSessionManager: DESIGN NOTE only — detached sessions on project re-entry
are kept alive intentionally and not yet evicted; bounding the idle class is
deferred (needs a per-session backend close primitive).
This commit is contained in:
parent
f10430aea2
commit
6d878e175c
173 changed files with 20617 additions and 1373 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
15
__mocks__/react-resizable-panels.js
vendored
Normal file
15
__mocks__/react-resizable-panels.js
vendored
Normal file
|
|
@ -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");
|
||||
|
|
@ -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 `<AgentChat key={session.internalId} />`, 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<TItem>` 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<string, AgentSession>` 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<TItem>` — 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 `<project_context>` 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/<vaultId>/context-cache/
|
||||
remotes/ web-<md5(url)>.md · youtube-<md5(url)>.md # shared by all projects
|
||||
files/ file-<md5(vaultPath)>.md # shared by all projects
|
||||
markers/ <md5(projectId)>/failed-<type>-<md5(source)>.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: { "<cacheRoot>/**": "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/<id>/` (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.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ module.exports = {
|
|||
"^yaml$": "<rootDir>/node_modules/yaml/dist/index.js",
|
||||
"^@agentclientprotocol/sdk$": "<rootDir>/__mocks__/@agentclientprotocol/sdk.js",
|
||||
"^@anthropic-ai/claude-agent-sdk$": "<rootDir>/__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$": "<rootDir>/__mocks__/react-resizable-panels.js",
|
||||
},
|
||||
testRegex: ".*\\.test\\.(jsx?|tsx?)$",
|
||||
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<typeof client.sessionUpdate>[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<typeof client.sessionUpdate>[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<typeof client.sessionUpdate>[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<typeof client.sessionUpdate>[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<typeof client.sessionUpdate>[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<typeof client2.sessionUpdate>[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<AcpBackendProcess> {
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<AcpCapability, boolean>();
|
||||
private readonly sessionWireState = new Map<SessionId, SessionWireState>();
|
||||
// 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<SessionId, Set<string>>();
|
||||
|
||||
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<OpenSessionOutput> {
|
||||
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<BackendState> {
|
||||
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<string> {
|
||||
let ids = this.todoToolCallIdsBySession.get(sessionId);
|
||||
if (!ids) {
|
||||
ids = new Set<string>();
|
||||
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(
|
||||
|
|
|
|||
184
src/agentMode/acp/wireTranslate.test.ts
Normal file
184
src/agentMode/acp/wireTranslate.test.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import type { SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { acpNotificationToEvents } from "./wireTranslate";
|
||||
|
||||
const SESSION_ID = "sess-1";
|
||||
|
||||
function notification(update: Record<string, unknown>): 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<string>();
|
||||
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<string>();
|
||||
// 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<string>();
|
||||
// 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<string>();
|
||||
// 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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string>
|
||||
): 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<string>
|
||||
): 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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, { permission?: Record<string, unknown> }>;
|
||||
};
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<Record<CopilotMode, stri
|
|||
export interface OpencodeModelDeps {
|
||||
providerRegistry: ProviderRegistry;
|
||||
backendConfigRegistry: BackendConfigRegistry;
|
||||
/**
|
||||
* Resolves the off-vault shared conversions cache root (absolute path) for
|
||||
* this vault, or `undefined` when unavailable. Injected so this backend never
|
||||
* reimplements vaultId/path derivation — that lives in
|
||||
* `context/conversionsLocation.ts`. When omitted, the opencode
|
||||
* `external_directory` allow rule is simply not injected (feature dormant).
|
||||
*/
|
||||
getCacheRoot?: () => string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -75,15 +83,47 @@ export class OpencodeBackend implements AcpBackend {
|
|||
}
|
||||
|
||||
async buildSpawnDescriptor(ctx: { vaultBasePath: string }): Promise<AcpSpawnDescriptor> {
|
||||
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 <vault>`
|
||||
// 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<Record<string, unknown>> {
|
||||
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 `{ "<glob>": "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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <project_instructions> 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<project_instructions>\nOnly cite notes tagged #verified.\n</project_instructions>`
|
||||
);
|
||||
});
|
||||
|
||||
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<project_instructions>\nproject body\n</project_instructions>`
|
||||
);
|
||||
expect(prompt.endsWith("</project_instructions>")).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("</user_custom_instructions>")).toBeLessThan(
|
||||
prompt.indexOf("<project_instructions>")
|
||||
);
|
||||
});
|
||||
|
||||
it("never emits a <project_context> block in the system prompt (it rides the first user prompt now)", () => {
|
||||
const prompt = buildAgentSystemPrompt({ projectInstructions: "PROJECT BODY" });
|
||||
expect(prompt).not.toContain("<project_context>");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("COPILOT_PROMPT_BASE", () => {
|
||||
|
|
|
|||
|
|
@ -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 `<project_instructions>` (mirroring `<user_custom_instructions>`)
|
||||
* 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 `<project_context>` 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 `<project_instructions>` / 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(`<user_custom_instructions>\n${userPrompt}\n</user_custom_instructions>`);
|
||||
}
|
||||
|
||||
// Optional project-instructions block, last. Absent/blank → nothing pushed,
|
||||
// so the global (no-project) prompt stays byte-identical. Wrapped in
|
||||
// `<project_instructions>`, mirroring the `<user_custom_instructions>` block
|
||||
// above — the plugin's convention for tagging opaque user content.
|
||||
const projectInstructions = opts?.projectInstructions?.trim();
|
||||
if (projectInstructions) {
|
||||
parts.push(`<project_instructions>\n${projectInstructions}\n</project_instructions>`);
|
||||
}
|
||||
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> })
|
||||
.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();
|
||||
|
|
|
|||
|
|
@ -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: <sessionId>` (continue
|
||||
* the persisted conversation) or `sessionId: <ourId>` (mint a new SDK-side
|
||||
|
|
@ -91,6 +102,18 @@ interface SessionState {
|
|||
*/
|
||||
effort?: EffortLevel;
|
||||
mcpServers: Record<string, McpServerConfig>;
|
||||
/**
|
||||
* 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<OpenSessionOutput> {
|
||||
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<string, McpServerConfig> = {};
|
||||
|
|
@ -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<ResumeSessionOutput> {
|
||||
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<void> {
|
||||
this.shuttingDown = true;
|
||||
for (const session of this.sessions.values()) {
|
||||
|
|
|
|||
284
src/agentMode/sdk/claudeTodoPlan.test.ts
Normal file
284
src/agentMode/sdk/claudeTodoPlan.test.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import {
|
||||
createClaudeTaskPlanState,
|
||||
planUpdateFromClaudeToolResult,
|
||||
planUpdateFromClaudeToolUse,
|
||||
} from "./claudeTodoPlan";
|
||||
|
||||
function entriesOf(update: ReturnType<typeof planUpdateFromClaudeToolUse>) {
|
||||
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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
238
src/agentMode/sdk/claudeTodoPlan.ts
Normal file
238
src/agentMode/sdk/claudeTodoPlan.ts
Normal file
|
|
@ -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: <subject>"` (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<string, { subject: string }>;
|
||||
tasksById: Map<string, { subject: string; status: TodoStatus; order: number }>;
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
|
@ -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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
/** 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}"`);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<AgentMessagePart, { kind: "plan" }>["entries"],
|
||||
b: Extract<AgentMessagePart, { kind: "plan" }>["entries"]
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
"<project_context>\n## Included folders\n- `/vault/Papers`\n</project_context>";
|
||||
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("<project_context>");
|
||||
expect(text).toContain("`/vault/Papers`");
|
||||
// Project context leads, then the per-message attached context, then the message.
|
||||
expect(text.indexOf("<project_context>")).toBeLessThan(text.indexOf("<copilot-context>"));
|
||||
expect(text.indexOf("<copilot-context>")).toBeLessThan(text.indexOf("<user-message>"));
|
||||
});
|
||||
|
||||
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("<project_context>");
|
||||
});
|
||||
});
|
||||
|
||||
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<typeof makeMockBackend>) {
|
||||
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<FanoutTurn> => {
|
||||
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 =
|
||||
"<project_context>\n## Included folders\n- `/vault/Papers`\n</project_context>";
|
||||
|
||||
// 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("<project_context>");
|
||||
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("<project_context>");
|
||||
});
|
||||
});
|
||||
|
||||
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 <project_context> 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:
|
||||
"<project_context>\n## Included folders\n- `/vault/Papers`\n</project_context>",
|
||||
}),
|
||||
});
|
||||
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("<project_context>");
|
||||
expect(promptText(0)).toContain("`/vault/Papers`");
|
||||
expect(promptText(0)).toContain("<user-message>\nfirst\n</user-message>");
|
||||
// The block rides the first message only; the second turn is clean.
|
||||
expect(promptText(1)).not.toContain("<project_context>");
|
||||
});
|
||||
|
||||
it("re-injects the <project_context> 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:
|
||||
"<project_context>\n## Included folders\n- `/vault/Papers`\n</project_context>",
|
||||
}),
|
||||
});
|
||||
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("<project_context>");
|
||||
expect(promptText(1)).toContain("<project_context>");
|
||||
});
|
||||
|
||||
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<typeof makeMockBackend>) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<BackendId> = 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 `<project_context>` block, captured for injection into this
|
||||
* session's first user prompt.
|
||||
*/
|
||||
contextReady?: Promise<ContextMaterializationResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -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<void>;
|
||||
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<ContextMaterializationResult> | null;
|
||||
// The project's `<project_context>` 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<void> {
|
||||
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 `<project_context>` 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
|
||||
// `<web_*>` 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 `<web_*>` 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 `<copilot-context>` envelope listing attached vault paths and
|
||||
* inlining note excerpts. Returns `null` when there's nothing to attach.
|
||||
* Build the `<copilot-context>` 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
|
||||
* `<project_context>` block, which lists the project's configured sources.
|
||||
*/
|
||||
function buildContextEnvelope(context: MessageContext | undefined): string | null {
|
||||
if (!context) return null;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
53
src/agentMode/session/agentChatYaml.ts
Normal file
53
src/agentMode/session/agentChatYaml.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
28
src/agentMode/session/scope.ts
Normal file
28
src/agentMode/session/scope.ts
Normal file
|
|
@ -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;
|
||||
79
src/agentMode/session/sessionScope.ts
Normal file
79
src/agentMode/session/sessionScope.ts
Normal file
|
|
@ -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)];
|
||||
}
|
||||
|
|
@ -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<OpenSessionOutput>;
|
||||
prompt(params: PromptInput): Promise<PromptOutput>;
|
||||
|
|
@ -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<void>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<boolean, []>();
|
||||
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<unknown> | undefined;
|
||||
jest.mock("@/components/chat-components/ChatInput", () => ({
|
||||
__esModule: true,
|
||||
default: (props: { agentBrands?: ReadonlyArray<unknown> }) => {
|
||||
default: (props: { agentBrands?: ReadonlyArray<unknown>; handleSendMessage?: () => void }) => {
|
||||
capturedAgentBrands = props.agentBrands;
|
||||
return null;
|
||||
return (
|
||||
<button type="button" onClick={() => props.handleSendMessage?.()}>
|
||||
send
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// 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(<AgentChatInput {...props} />);
|
||||
const makeDraft = (overrides: Partial<AgentInputDraftControls> = {}): 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<React.ComponentProps<typeof AgentChatInput>> = {}
|
||||
) {
|
||||
return render(
|
||||
<AgentChatInput
|
||||
backend={backend}
|
||||
sessionId="session-1"
|
||||
draft={draft}
|
||||
app={makeApp()}
|
||||
mainAgentId={null}
|
||||
updateUserMessageHistory={jest.fn()}
|
||||
isStarting={false}
|
||||
hasPendingPlanPermission={false}
|
||||
modelPickerOverride={undefined}
|
||||
modePickerOverride={undefined}
|
||||
onCycleMode={jest.fn()}
|
||||
{...extraProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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<void>((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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 && <MultiAgentUpsellHint />}
|
||||
<div
|
||||
className={hasPendingPlanPermission ? "tw-pointer-events-none tw-opacity-50" : undefined}
|
||||
aria-disabled={hasPendingPlanPermission || undefined}
|
||||
className={
|
||||
hasPendingPlanPermission || disabled ? "tw-pointer-events-none tw-opacity-50" : undefined
|
||||
}
|
||||
aria-disabled={hasPendingPlanPermission || disabled || undefined}
|
||||
>
|
||||
{/* 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}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
119
src/agentMode/ui/AgentContextSection.test.tsx
Normal file
119
src/agentMode/ui/AgentContextSection.test.tsx
Normal file
|
|
@ -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> = {}): 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(<AgentContextSection app={app} projectId="missing" />);
|
||||
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(<AgentContextSection app={app} projectId="p1" />);
|
||||
|
||||
// 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(<AgentContextSection app={app} projectId="p1" />);
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
197
src/agentMode/ui/AgentContextSection.tsx
Normal file
197
src/agentMode/ui/AgentContextSection.tsx
Normal file
|
|
@ -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<ProjectConfig["contextSource"]>;
|
||||
|
||||
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<ContextSource | undefined>(externalContext);
|
||||
const pendingRef = useRef<Partial<ContextSource> | 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<ContextSource>) => {
|
||||
setDraft((prev) => ({ ...(prev ?? {}), ...patch }));
|
||||
pendingRef.current = { ...(pendingRef.current ?? {}), ...patch };
|
||||
flush();
|
||||
},
|
||||
[flush]
|
||||
);
|
||||
|
||||
const sectionRef = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={sectionRef}
|
||||
data-copilot-drop-zone
|
||||
className={cn(SHELF_BODY_FLOOR_CLASS, "tw-flex tw-grow tw-flex-col tw-p-2")}
|
||||
>
|
||||
<ProjectContextSourceEditor
|
||||
contextSource={draft}
|
||||
onChange={handleChange}
|
||||
onManage={handleManage}
|
||||
popoverContainer={popoverContainer}
|
||||
isDragging={isDragging}
|
||||
// Fill the shelf floor; the editor's own max-height caps it so a long
|
||||
// context scrolls inside the box instead of taking over the whole tab.
|
||||
className="tw-grow"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
252
src/agentMode/ui/AgentContextStatusIcon.test.tsx
Normal file
252
src/agentMode/ui/AgentContextStatusIcon.test.tsx
Normal file
|
|
@ -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> = {}): 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(
|
||||
<AgentContextStatusIcon
|
||||
app={APP}
|
||||
activeProjectId={PROJECT}
|
||||
project={PROJECT_CONFIG}
|
||||
hasConfiguredContextSource
|
||||
landing={false}
|
||||
onReindex={() => 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();
|
||||
});
|
||||
});
|
||||
479
src/agentMode/ui/AgentContextStatusIcon.tsx
Normal file
479
src/agentMode/ui/AgentContextStatusIcon.tsx
Normal file
|
|
@ -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<boolean>;
|
||||
/** 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<unknown>;
|
||||
/** 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<AgentProjectContextLoadState["phase"], number> = {
|
||||
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 <AlertCircle className="tw-size-4 tw-text-error" />;
|
||||
if (kind === "ready") return <CheckCircle className="tw-size-4 tw-text-success" />;
|
||||
if (kind === "idle") return <CircleDashed className="tw-size-4 tw-text-faint" />;
|
||||
return <Loader2 className="tw-size-4 tw-animate-spin tw-text-loading" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ProjectScopeId | null>(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 (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost2"
|
||||
size="fit"
|
||||
className="tw-text-muted"
|
||||
aria-label="Project context status"
|
||||
>
|
||||
<TriggerIcon kind={triggerKind} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{/* 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. */}
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side={landing ? "bottom" : "top"}
|
||||
className="tw-flex tw-max-h-[var(--radix-popover-content-available-height)] tw-w-[368px] tw-max-w-[calc(100vw-24px)] tw-flex-col tw-overflow-hidden tw-p-0"
|
||||
>
|
||||
{/* Mounted only while open, so the per-source read-model (disk + atom)
|
||||
runs on demand rather than for every composer render. */}
|
||||
{open && (
|
||||
<ConversionPopoverBody
|
||||
app={app}
|
||||
project={project}
|
||||
hasConfiguredContextSource={hasConfiguredContextSource}
|
||||
onRetryItem={handleRetryItem}
|
||||
onRetryAll={handleRetryAll}
|
||||
onEditContext={() => {
|
||||
handleOpenChange(false);
|
||||
onEditContext();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<AgentContextConversionModalContent
|
||||
items={items}
|
||||
hasConfiguredContextSource={hasConfiguredContextSource}
|
||||
skippedMarkdownCount={skippedMarkdownCount}
|
||||
onRetryItem={onRetryItem}
|
||||
onRetryAll={onRetryAll}
|
||||
onEditContext={onEditContext}
|
||||
onOpenCachedItem={handleOpenCachedItem}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<AgentHomeProps> = ({
|
||||
backend,
|
||||
|
|
@ -95,10 +116,16 @@ const AgentHomeInternal: React.FC<AgentHomeProps> = ({
|
|||
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<HTMLDivElement | null>(null);
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// External callers (CopilotPlugin.autosaveCurrentChat → CopilotAgentView.saveChat)
|
||||
|
|
@ -153,22 +180,154 @@ const AgentHomeInternal: React.FC<AgentHomeProps> = ({
|
|||
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<HTMLElement | null>(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<AgentHomeProps> = ({
|
|||
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 `<project_context>` 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<boolean> => {
|
||||
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<AgentHomeProps> = ({
|
|||
// 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<string | null>(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<AgentHomeProps> = ({
|
|||
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: <Folder className="tw-size-4" />,
|
||||
title: "Projects",
|
||||
count: projects.length,
|
||||
disabled: true,
|
||||
disabledTooltip: "Coming soon",
|
||||
renderBody: () => (
|
||||
<ProjectPickerList
|
||||
projects={projects}
|
||||
onSelect={handleProjectComingSoon}
|
||||
onCreate={handleProjectComingSoon}
|
||||
onSelect={handleSelectProject}
|
||||
onCreate={handleCreateProject}
|
||||
app={app}
|
||||
onProjectDeleted={handleProjectDeleted}
|
||||
projectUsageTimestampsManager={projectUsageManager}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
@ -284,136 +553,362 @@ const AgentHomeInternal: React.FC<AgentHomeProps> = ({
|
|||
[
|
||||
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<AgentHomeShelfSection[]>(
|
||||
() => [
|
||||
{
|
||||
id: "project-chats",
|
||||
icon: <MessageSquare className="tw-size-4" />,
|
||||
title: "Recent Chats",
|
||||
count: chatHistoryItems.length,
|
||||
renderBody: () => (
|
||||
<GlobalRecentChatsSection
|
||||
items={chatHistoryItems}
|
||||
variant="project"
|
||||
onLoadChat={handleLoadChat}
|
||||
onUpdateTitle={handleUpdateChatTitle}
|
||||
onDeleteChat={handleDeleteChat}
|
||||
onOpenSourceFile={handleOpenSourceFile}
|
||||
onLoadHistory={handleLoadChatHistory}
|
||||
runningChatIds={runningChatIds}
|
||||
attentionChatIds={attentionChatIds}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "project-context",
|
||||
icon: <Files className="tw-size-4" />,
|
||||
title: "Context",
|
||||
count: contextSummary.totalItems,
|
||||
renderBody: () => (
|
||||
<AgentContextSection app={app} projectId={activeProjectId} popoverContainer={rootEl} />
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
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 ? (
|
||||
<AgentContextStatusIcon
|
||||
app={app}
|
||||
activeProjectId={activeProjectId}
|
||||
project={activeProject}
|
||||
hasConfiguredContextSource={!contextSummary.isEmpty}
|
||||
landing={isLanding}
|
||||
onReindex={() => 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 = (
|
||||
<AgentChatInput
|
||||
backend={backend}
|
||||
sessionId={sessionId}
|
||||
draft={draft}
|
||||
app={app}
|
||||
mainAgentId={mainAgentId}
|
||||
updateUserMessageHistory={updateUserMessageHistory}
|
||||
isStarting={isStarting}
|
||||
hasPendingPlanPermission={hasPendingPlanPermission}
|
||||
modelPickerOverride={modelPickerOverride ?? undefined}
|
||||
modePickerOverride={modePickerOverride ?? undefined}
|
||||
onCycleMode={handleCycleMode}
|
||||
activeProjectId={activeProjectId}
|
||||
contextLoadBlocking={contextLoadBlocking}
|
||||
disabled={isOrphanedProject}
|
||||
contextStatusIndicator={contextStatusIndicator}
|
||||
/>
|
||||
);
|
||||
|
||||
// Hero: a single title line above the composer — the rotating greeting
|
||||
// (global) or "Chat in <project>" (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 = (
|
||||
<div className="tw-flex tw-min-w-0 tw-items-center tw-justify-center tw-gap-3">
|
||||
<CopilotBrandIcon className="tw-size-4 tw-shrink-0 tw-text-normal" />
|
||||
{/* 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. */}
|
||||
<TruncatedText
|
||||
className="tw-min-w-0 tw-text-ui-title tw-font-[330] tw-text-normal"
|
||||
tooltipContent={heroText}
|
||||
>
|
||||
{heroText}
|
||||
</TruncatedText>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="tw-flex tw-size-full tw-flex-col tw-overflow-hidden">
|
||||
<div ref={setRootEl} className="tw-flex tw-size-full tw-flex-col tw-overflow-hidden">
|
||||
{/* 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. */}
|
||||
<div
|
||||
className={cn(
|
||||
"tw-grid tw-shrink-0 tw-transition-[grid-template-rows,opacity] tw-duration-200 tw-ease-out motion-reduce:tw-transition-none",
|
||||
isProjectScope
|
||||
? "tw-grid-rows-[1fr] tw-opacity-100"
|
||||
: "tw-pointer-events-none tw-grid-rows-[0fr] tw-opacity-0"
|
||||
)}
|
||||
aria-hidden={!isProjectScope}
|
||||
>
|
||||
<div className="tw-min-h-0 tw-overflow-hidden">
|
||||
<AgentProjectHeader
|
||||
projectId={headerProjectId}
|
||||
projectName={headerName}
|
||||
onExit={handleExitProject}
|
||||
orphaned={headerOrphaned}
|
||||
menu={
|
||||
activeProject ? (
|
||||
<ProjectInfoPopover
|
||||
app={app}
|
||||
project={activeProject}
|
||||
todoList={currentTodoList}
|
||||
// The header sits OUTSIDE chatContainerRef, so the popover
|
||||
// portals into the AgentHome root for popout correctness.
|
||||
container={rootEl}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AgentTabStrip manager={manager} />
|
||||
{createAnchor && (
|
||||
<CreateProjectPanel
|
||||
anchorEl={createAnchor}
|
||||
onClose={() => setCreateAnchor(null)}
|
||||
onSave={persistCreateProject}
|
||||
/>
|
||||
)}
|
||||
<div className="tw-min-h-0 tw-flex-1">
|
||||
<div ref={chatContainerRef} className="tw-flex tw-size-full tw-flex-col tw-overflow-hidden">
|
||||
<div className="tw-h-full">
|
||||
<div className="tw-relative tw-flex tw-h-full tw-flex-col">
|
||||
{isDragActive && (
|
||||
<div className="tw-absolute tw-inset-0 tw-z-modal tw-flex tw-items-center tw-justify-center tw-rounded-md tw-border tw-border-dashed tw-bg-primary tw-opacity-80">
|
||||
// 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.
|
||||
<div className="tw-pointer-events-none tw-absolute tw-inset-0 tw-z-modal tw-flex tw-items-center tw-justify-center tw-rounded-md tw-border tw-border-dashed tw-bg-primary tw-opacity-80">
|
||||
<span>Drop files here...</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 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). */}
|
||||
<div
|
||||
className={
|
||||
// Landing: overflow-y-auto, not hidden. With room, the flex
|
||||
// spacers fill the column exactly so no scrollbar shows; on a
|
||||
// pane too short to fit the status/title/composer stack the
|
||||
// column scrolls instead of clipping them out of reach (small
|
||||
// Obsidian splits / popouts). Conversation: the transcript owns
|
||||
// its own scroll, so this stays hidden.
|
||||
isGlobalLanding
|
||||
// Landing: overflow-y-auto, not hidden — the h-1/4 spacer +
|
||||
// flex-1 shelf fill the column exactly when there's room, and on
|
||||
// a pane too short to fit the stack the column scrolls instead of
|
||||
// clipping it out of reach. Conversation: the transcript owns its
|
||||
// own scroll, so this stays hidden.
|
||||
isLanding
|
||||
? "tw-flex tw-size-full tw-flex-col tw-overflow-y-auto tw-px-2"
|
||||
: "tw-flex tw-size-full tw-flex-col tw-overflow-hidden"
|
||||
}
|
||||
data-agent-landing={isGlobalLanding ? "global" : "conversation"}
|
||||
data-agent-landing={
|
||||
isProjectLanding ? "project" : isLanding ? "global" : "conversation"
|
||||
}
|
||||
>
|
||||
<AgentModeStatus manager={manager} plugin={plugin} onInstallClick={handleInstall} />
|
||||
{isGlobalLanding ? (
|
||||
<>
|
||||
{/* Top spacer at a FIXED fraction of the column height
|
||||
(h-1/4), not a flex grow. This top-anchors the composer:
|
||||
the spacer is a percentage of the (constant) column
|
||||
height, so it stays put when the composer's own height
|
||||
changes — e.g. removing the active-note context chip. A
|
||||
flex spacer would instead re-divide the freed space and
|
||||
push the greeting + composer down (the "top drops" we want
|
||||
to avoid). The lower shelf region (flex-1) absorbs the
|
||||
change instead, so the composer's lower half and the shelf
|
||||
collapse upward while the greeting stays fixed. */}
|
||||
<div className="tw-h-1/4 tw-shrink-0" />
|
||||
<div className="tw-shrink-0 tw-pb-7">
|
||||
<div className="tw-flex tw-items-center tw-justify-center tw-gap-3">
|
||||
<CopilotBrandIcon className="tw-size-4 tw-text-normal" />
|
||||
{/* 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. */}
|
||||
<span className="tw-text-ui-title tw-font-[330] tw-text-normal">
|
||||
{greeting}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
{isLanding ? (
|
||||
<AgentLandingStack
|
||||
hero={hero}
|
||||
composer={composerNode}
|
||||
floating={
|
||||
// Global landing with no projects yet → the dismissible
|
||||
// Welcome card. (Project context status now lives on the
|
||||
// composer's status icon, not a floating load card.)
|
||||
!isProjectLanding &&
|
||||
projects.length === 0 &&
|
||||
!settings.agentMode.welcomeDismissed ? (
|
||||
<AgentWelcomeCard
|
||||
onCreate={handleCreateProject}
|
||||
onDismiss={handleDismissWelcome}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
context={
|
||||
// Zero-chat project landing: the Context body standalone
|
||||
// below the composer (mutually exclusive with the shelf;
|
||||
// see projectPlacement). Skipped for an orphaned id — the
|
||||
// body renders null for an unknown project and the
|
||||
// wrapper would leave a stray padded gap.
|
||||
isProjectLanding && !isOrphanedProject && projectPlacement?.standalone ? (
|
||||
<div className="tw-px-2 tw-pb-1 tw-pt-3">
|
||||
<AgentContextSection
|
||||
app={app}
|
||||
projectId={activeProjectId}
|
||||
popoverContainer={rootEl}
|
||||
/>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
shelf={
|
||||
isProjectLanding ? (
|
||||
// Tabbed Recent Chats / Context card, only once the
|
||||
// project has chats. Keyed by project so the selected
|
||||
// tab resets instead of leaking across scope switches
|
||||
// (the global and project shelves share this slot).
|
||||
// null while the placement is undecided (history not
|
||||
// settled) or standalone — the stack drops the shelf
|
||||
// wrapper entirely so no empty gap remains.
|
||||
projectPlacement && !projectPlacement.standalone ? (
|
||||
<AgentHomeShelf key={activeProjectId} sections={projectLandingSections} />
|
||||
) : null
|
||||
) : (
|
||||
<AgentHomeShelf
|
||||
sections={landingSections}
|
||||
activeSectionId={globalShelfTab}
|
||||
onSectionSelect={setGlobalShelfTab}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<AgentChatMessages
|
||||
messages={messages}
|
||||
app={app}
|
||||
currentPlan={currentPlan}
|
||||
pendingToolPermissions={pendingToolPermissions}
|
||||
pendingAskUserQuestions={pendingAskUserQuestions}
|
||||
chatBackend={backend}
|
||||
isLoading={draft.loading}
|
||||
/>
|
||||
<>
|
||||
<AgentChatMessages
|
||||
messages={messages}
|
||||
app={app}
|
||||
currentPlan={currentPlan}
|
||||
pendingToolPermissions={pendingToolPermissions}
|
||||
pendingAskUserQuestions={pendingAskUserQuestions}
|
||||
chatBackend={backend}
|
||||
isLoading={draft.loading}
|
||||
/>
|
||||
<AgentChatControls
|
||||
onNewChat={handleNewChat}
|
||||
onSaveAsNote={handleSaveAsNote}
|
||||
chatHistoryItems={chatHistoryItems}
|
||||
onLoadHistory={handleLoadChatHistory}
|
||||
onLoadChat={handleLoadChat}
|
||||
onUpdateChatTitle={handleUpdateChatTitle}
|
||||
onDeleteChat={handleDeleteChat}
|
||||
onOpenSourceFile={handleOpenSourceFile}
|
||||
/>
|
||||
{composerNode}
|
||||
</>
|
||||
)}
|
||||
{isGlobalLanding ? null : (
|
||||
<AgentChatControls
|
||||
onNewChat={handleNewChat}
|
||||
onSaveAsNote={handleSaveAsNote}
|
||||
chatHistoryItems={chatHistoryItems}
|
||||
onLoadHistory={handleLoadChatHistory}
|
||||
onLoadChat={handleLoadChat}
|
||||
onUpdateChatTitle={handleUpdateChatTitle}
|
||||
onDeleteChat={handleDeleteChat}
|
||||
onOpenSourceFile={handleOpenSourceFile}
|
||||
/>
|
||||
)}
|
||||
<div className={isGlobalLanding ? "tw-shrink-0" : undefined}>
|
||||
<AgentChatInput
|
||||
backend={backend}
|
||||
sessionId={sessionId}
|
||||
draft={draft}
|
||||
app={app}
|
||||
mainAgentId={mainAgentId}
|
||||
updateUserMessageHistory={updateUserMessageHistory}
|
||||
isStarting={isStarting}
|
||||
hasPendingPlanPermission={hasPendingPlanPermission}
|
||||
modelPickerOverride={modelPickerOverride ?? undefined}
|
||||
modePickerOverride={modePickerOverride ?? undefined}
|
||||
onCycleMode={handleCycleMode}
|
||||
/>
|
||||
</div>
|
||||
{isGlobalLanding ? (
|
||||
/* Lower region below the composer holds the tabbed shelf
|
||||
(Projects / Recent Chats). flex-1 so it absorbs every change
|
||||
in the composer's height: when the composer shrinks (e.g. the
|
||||
context chip is removed) this region grows and its top-aligned
|
||||
card moves up — the composer above stays top-anchored. The
|
||||
shelf is a persistent card that sizes to its content and sits
|
||||
at the top of this region. This region owns no scroll of its
|
||||
own — on a pane too short to fit the card, the card caps to the
|
||||
region height (min-h-0 chain) and scrolls its list internally,
|
||||
keeping the tab bar in reach. No extra horizontal padding so
|
||||
the card lines up with the composer's border. */
|
||||
<div className="tw-flex tw-min-h-0 tw-flex-1 tw-flex-col tw-pt-6">
|
||||
<AgentHomeShelf sections={landingSections} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<Button
|
||||
type="button"
|
||||
variant="ghost2"
|
||||
onClick={onClick}
|
||||
onClick={(e) => onClick(e.currentTarget)}
|
||||
aria-label={label}
|
||||
className="tw-h-auto tw-min-h-9 tw-w-full tw-justify-start tw-gap-2 tw-rounded-md tw-px-2 tw-py-1.5 hover:tw-bg-modifier-hover"
|
||||
>
|
||||
|
|
@ -87,6 +94,19 @@ interface AgentHomeListRowProps {
|
|||
* tile (tinted square + colored folder). Takes precedence over `icon`.
|
||||
*/
|
||||
leading?: React.ReactNode;
|
||||
/**
|
||||
* Optional control that **replaces** the relative time on hover / keyboard
|
||||
* focus — e.g. a project row's inline action cluster — mirroring the
|
||||
* chat-history rows (the time is the resting right-edge element; the control
|
||||
* takes its slot when the row is active). The row carries `tw-group`, so the
|
||||
* swap is pure CSS: the time hides and this slot shows on `group-hover` /
|
||||
* `group-focus-within`, plus `group-has-[[data-state=open]]` so any portaled
|
||||
* popover a slot control opens stays visible after the pointer leaves.
|
||||
* Pointer/keyboard events inside the
|
||||
* slot are kept from bubbling to the row's `onClick`, so opening the menu never
|
||||
* also fires the row action.
|
||||
*/
|
||||
trailing?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -103,13 +123,14 @@ export const AgentHomeListRow = memo(function AgentHomeListRow({
|
|||
indent = false,
|
||||
icon: Icon,
|
||||
leading,
|
||||
trailing,
|
||||
}: AgentHomeListRowProps): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"tw-flex tw-min-h-9 tw-w-full tw-cursor-pointer tw-items-center tw-gap-2 tw-rounded-md tw-px-2 tw-py-1.5",
|
||||
"tw-group tw-flex tw-min-h-9 tw-w-full tw-cursor-pointer tw-items-center tw-gap-2 tw-rounded-md tw-px-2 tw-py-1.5",
|
||||
"tw-text-left tw-transition-colors hover:tw-bg-modifier-hover"
|
||||
)}
|
||||
onClick={onClick}
|
||||
|
|
@ -131,11 +152,31 @@ export const AgentHomeListRow = memo(function AgentHomeListRow({
|
|||
{label}
|
||||
</span>
|
||||
<span
|
||||
className="tw-shrink-0 tw-whitespace-nowrap tw-text-xs tw-text-muted"
|
||||
className={cn(
|
||||
"tw-shrink-0 tw-whitespace-nowrap tw-text-xs tw-text-muted",
|
||||
// Reason: only when a `trailing` control exists does it replace the
|
||||
// time on hover/focus — a row without one keeps the time always shown.
|
||||
trailing &&
|
||||
"group-focus-within:tw-hidden group-hover:tw-hidden group-has-[[data-state=open]]:tw-hidden"
|
||||
)}
|
||||
title={new Date(timeMs).toLocaleString()}
|
||||
>
|
||||
{formatCompactRelativeTime(timeMs)}
|
||||
</span>
|
||||
{trailing && (
|
||||
// Reason: the slot owns an independent control (e.g. an overflow menu),
|
||||
// so stop pointer/keyboard events from bubbling to the row's onClick —
|
||||
// otherwise opening the menu would also trigger the row action. Hidden at
|
||||
// rest; takes the time's slot on hover/focus (or while its dropdown is
|
||||
// open) so the row's right edge never shows both at once.
|
||||
<span
|
||||
className="tw-hidden tw-shrink-0 tw-items-center group-focus-within:tw-flex group-hover:tw-flex group-has-[[data-state=open]]:tw-flex"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{trailing}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,14 +9,25 @@ beforeAll(() => {
|
|||
(window as unknown as { activeDocument: Document }).activeDocument = window.document;
|
||||
});
|
||||
|
||||
function renderShelf(sections: AgentHomeShelfSection[]) {
|
||||
function renderShelf(
|
||||
sections: AgentHomeShelfSection[],
|
||||
controlled?: { activeSectionId: string | null; onSectionSelect?: (id: string) => void }
|
||||
) {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<AgentHomeShelf sections={sections} />
|
||||
<AgentHomeShelf sections={sections} {...controlled} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const projectsEnabled: AgentHomeShelfSection = {
|
||||
id: "projects",
|
||||
icon: <span />,
|
||||
title: "Projects",
|
||||
count: 5,
|
||||
renderBody: () => <div>PROJECTS BODY</div>,
|
||||
};
|
||||
|
||||
const chats: AgentHomeShelfSection = {
|
||||
id: "chats",
|
||||
icon: <span />,
|
||||
|
|
@ -57,3 +68,26 @@ describe("AgentHomeShelf with a disabled section", () => {
|
|||
expect(screen.queryByText("CHATS BODY")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentHomeShelf controlled mode", () => {
|
||||
it("renders the parent-selected section's body", () => {
|
||||
renderShelf([chats, projectsEnabled], { activeSectionId: "projects" });
|
||||
expect(screen.queryByText("PROJECTS BODY")).not.toBeNull();
|
||||
expect(screen.queryByText("CHATS BODY")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the first selectable section when nothing is picked yet (null)", () => {
|
||||
renderShelf([chats, projectsEnabled], { activeSectionId: null });
|
||||
expect(screen.queryByText("CHATS BODY")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reports clicks via onSectionSelect instead of switching on its own", () => {
|
||||
const onSectionSelect = jest.fn();
|
||||
renderShelf([chats, projectsEnabled], { activeSectionId: "chats", onSectionSelect });
|
||||
fireEvent.click(screen.getByRole("tab", { name: /Projects/ }));
|
||||
expect(onSectionSelect).toHaveBeenCalledWith("projects");
|
||||
// Controlled: the body only changes when the parent updates the prop.
|
||||
expect(screen.queryByText("CHATS BODY")).not.toBeNull();
|
||||
expect(screen.queryByText("PROJECTS BODY")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,16 @@ import { AgentHomeTab } from "@/agentMode/ui/AgentHomeTab";
|
|||
import { cn } from "@/lib/utils";
|
||||
import React, { useId, useState } from "react";
|
||||
|
||||
/**
|
||||
* Reserved floor for the shelf's panel body. Deliberately a *floor*, not an
|
||||
* equal-height lock: full tabs (INLINE_LIMIT rows) all land within ~10px of
|
||||
* each other naturally, and this floor just keeps short/empty lists from
|
||||
* collapsing the card — the standard reserve-space-to-avoid-layout-shift
|
||||
* pattern. AgentContextSection imports this for its standalone (no-shelf)
|
||||
* rendering so the two surfaces can't drift.
|
||||
*/
|
||||
export const SHELF_BODY_FLOOR_CLASS = "tw-min-h-48";
|
||||
|
||||
export interface AgentHomeShelfSection {
|
||||
/** Stable id used for tab selection. */
|
||||
id: string;
|
||||
|
|
@ -24,6 +34,22 @@ export interface AgentHomeShelfSection {
|
|||
interface AgentHomeShelfProps {
|
||||
sections: AgentHomeShelfSection[];
|
||||
className?: string;
|
||||
/**
|
||||
* Controlled selection: when provided (non-undefined), the parent owns the
|
||||
* active tab and must update it via `onSectionSelect`. Used by the global
|
||||
* landing so the selected tab survives this shelf unmounting while a project
|
||||
* is open. Omit for the default uncontrolled behavior (project shelves,
|
||||
* which deliberately reset per project via `key`).
|
||||
*
|
||||
* DESIGN NOTE: these are independent optionals, not a controlled/uncontrolled
|
||||
* union — matching the Radix/standard React convention (and `value`/`onChange`
|
||||
* pairs elsewhere in this repo). Passing `activeSectionId` without
|
||||
* `onSectionSelect` yields a read-only tab bar, which is legal controlled
|
||||
* behavior, not a footgun worth a union type; the only controlled caller
|
||||
* (AgentHome's global landing) passes both.
|
||||
*/
|
||||
activeSectionId?: string | null;
|
||||
onSectionSelect?: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -33,11 +59,26 @@ interface AgentHomeShelfProps {
|
|||
* section bodies are sized to lead with the same number of rows, so switching
|
||||
* tabs doesn't change the card height.
|
||||
*/
|
||||
export function AgentHomeShelf({ sections, className }: AgentHomeShelfProps): React.ReactElement {
|
||||
export function AgentHomeShelf({
|
||||
sections,
|
||||
className,
|
||||
activeSectionId,
|
||||
onSectionSelect,
|
||||
}: AgentHomeShelfProps): React.ReactElement {
|
||||
// Default to (and only ever resolve to) the first selectable section — a
|
||||
// disabled tab can't be activated, so its body never mounts.
|
||||
const firstSelectable = sections.find((s) => !s.disabled) ?? null;
|
||||
const [activeId, setActiveId] = useState<string | null>(firstSelectable?.id ?? null);
|
||||
const [internalActiveId, setInternalActiveId] = useState<string | null>(
|
||||
firstSelectable?.id ?? null
|
||||
);
|
||||
// Controlled when the parent passes `activeSectionId` (null counts as "parent
|
||||
// owns it, nothing picked yet" and resolves to the first selectable below).
|
||||
const isControlled = activeSectionId !== undefined;
|
||||
const activeId = isControlled ? activeSectionId : internalActiveId;
|
||||
const selectSection = (id: string) => {
|
||||
if (!isControlled) setInternalActiveId(id);
|
||||
onSectionSelect?.(id);
|
||||
};
|
||||
const requested = sections.find((s) => s.id === activeId);
|
||||
const active = requested && !requested.disabled ? requested : firstSelectable;
|
||||
const panelId = useId();
|
||||
|
|
@ -79,14 +120,15 @@ export function AgentHomeShelf({ sections, className }: AgentHomeShelfProps): Re
|
|||
controlsId={panelId}
|
||||
disabled={section.disabled}
|
||||
disabledTooltip={section.disabledTooltip}
|
||||
onClick={() => setActiveId(section.id)}
|
||||
onClick={() => selectSection(section.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* 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"
|
||||
>
|
||||
<div className="tw-min-h-48 tw-pb-1">{active.renderBody()}</div>
|
||||
<div className={cn(SHELF_BODY_FLOOR_CLASS, "tw-flex tw-flex-col tw-pb-1")}>
|
||||
{active.renderBody()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
70
src/agentMode/ui/AgentLandingStack.tsx
Normal file
70
src/agentMode/ui/AgentLandingStack.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import React from "react";
|
||||
|
||||
interface AgentLandingStackProps {
|
||||
/** Brand icon + greeting (global) or "Chat in <project>" 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 (
|
||||
<>
|
||||
<div className="tw-h-1/4 tw-shrink-0" />
|
||||
<div className="tw-shrink-0 tw-pb-7">{hero}</div>
|
||||
<div className="tw-shrink-0">{composer}</div>
|
||||
{/* 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 ? <div className="tw-shrink-0">{floating}</div> : null}
|
||||
{context ? <div className="tw-shrink-0">{context}</div> : null}
|
||||
{shelf ? (
|
||||
<div className="tw-flex tw-min-h-0 tw-flex-1 tw-flex-col tw-pt-6">{shelf}</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentLandingStack;
|
||||
98
src/agentMode/ui/AgentModeChat.test.tsx
Normal file
98
src/agentMode/ui/AgentModeChat.test.tsx
Normal file
|
|
@ -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(
|
||||
<AgentModeChat plugin={plugin} onSaveChat={() => {}} 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -53,7 +53,14 @@ export const AgentModeChat: React.FC<Props> = ({
|
|||
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;
|
||||
|
|
|
|||
83
src/agentMode/ui/AgentProjectCreateForm.test.tsx
Normal file
83
src/agentMode/ui/AgentProjectCreateForm.test.tsx
Normal file
|
|
@ -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<React.ComponentProps<typeof AgentProjectCreateForm>> = {}) {
|
||||
const onSave = props.onSave ?? jest.fn().mockResolvedValue(undefined);
|
||||
const onCancel = props.onCancel ?? jest.fn();
|
||||
render(<AgentProjectCreateForm {...props} onSave={onSave} onCancel={onCancel} />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
119
src/agentMode/ui/AgentProjectCreateForm.tsx
Normal file
119
src/agentMode/ui/AgentProjectCreateForm.tsx
Normal file
|
|
@ -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<void>;
|
||||
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 = (
|
||||
<Input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => 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 (
|
||||
<div className="tw-flex tw-flex-col tw-gap-4">
|
||||
{title && (
|
||||
<div className="tw-flex tw-flex-col tw-gap-1">
|
||||
<div className="tw-text-base tw-font-semibold tw-text-normal">{title}</div>
|
||||
{subtitle && <div className="tw-text-sm tw-text-muted">{subtitle}</div>}
|
||||
</div>
|
||||
)}
|
||||
{/* 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
|
||||
) : (
|
||||
<FormField label="Name" required>
|
||||
{nameInput}
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<div className="tw-flex tw-justify-end tw-gap-2">
|
||||
<Button variant="secondary" onClick={onCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="default" onClick={() => void handleSave()} disabled={!canSave}>
|
||||
{isSaving ? "Saving..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
57
src/agentMode/ui/AgentProjectHeader.test.tsx
Normal file
57
src/agentMode/ui/AgentProjectHeader.test.tsx
Normal file
|
|
@ -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<React.ComponentProps<typeof AgentProjectHeader>> = {}) {
|
||||
const onExit = props.onExit ?? jest.fn();
|
||||
const menu = "menu" in props ? props.menu : <button type="button" aria-label="Project options" />;
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<AgentProjectHeader
|
||||
projectId={props.projectId ?? "demo-project"}
|
||||
projectName={props.projectName ?? "Demo"}
|
||||
onExit={onExit}
|
||||
menu={menu}
|
||||
orphaned={props.orphaned}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
return { onExit };
|
||||
}
|
||||
|
||||
describe("AgentProjectHeader", () => {
|
||||
it("shows the live project name", () => {
|
||||
renderHeader({ projectName: "My Research" });
|
||||
expect(screen.getByText("My Research")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("exits the project when the back affordance is clicked", () => {
|
||||
const { onExit } = renderHeader();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Leave project" }));
|
||||
expect(onExit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders the provided options menu in the trailing slot", () => {
|
||||
renderHeader({ menu: <button type="button" aria-label="Project options" /> });
|
||||
expect(screen.getByLabelText("Project options")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("degrades to an escape hatch when the project is orphaned", () => {
|
||||
const { onExit } = renderHeader({ projectName: "Gone", orphaned: true });
|
||||
// No stale name or options menu pointing at a deleted project.
|
||||
expect(screen.queryByText("Gone")).toBeNull();
|
||||
expect(screen.queryByLabelText("Project options")).toBeNull();
|
||||
expect(screen.getByText("This project no longer exists")).toBeTruthy();
|
||||
// The back affordance still works so the user can leave the dead scope.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Leave project" }));
|
||||
expect(onExit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
77
src/agentMode/ui/AgentProjectHeader.tsx
Normal file
77
src/agentMode/ui/AgentProjectHeader.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { ProjectIconTile } from "@/agentMode/ui/ProjectIconTile";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import React, { memo } from "react";
|
||||
|
||||
interface AgentProjectHeaderProps {
|
||||
/** Stable project id — drives the identity tile's color (same hue as the Projects tab). */
|
||||
projectId: string;
|
||||
/** Live project name (read from `useProjects` by the parent so renames reflect). */
|
||||
projectName: string;
|
||||
/** Leave the project workspace back to the global scope. */
|
||||
onExit: () => void;
|
||||
/**
|
||||
* Per-project options control (the `⋯` overflow menu: Edit / Reveal / Delete),
|
||||
* rendered in the trailing slot. Passed as a node so this stays presentational
|
||||
* — the parent owns the menu component and its handlers. Omitted when orphaned.
|
||||
*/
|
||||
menu?: React.ReactNode;
|
||||
/**
|
||||
* The active project's record is gone (folder/`project.md` deleted while the
|
||||
* user was inside it). Degrades to just the `‹` escape hatch — no stale
|
||||
* name, tile, or menu pointing at a project that no longer exists.
|
||||
*/
|
||||
orphaned?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin workspace header shown above the chat surface whenever a project scope is
|
||||
* active (both the project landing and an in-project conversation). Presentational
|
||||
* only: the parent owns scope state and feeds the live `projectName`.
|
||||
*/
|
||||
export const AgentProjectHeader = memo(
|
||||
({
|
||||
projectId,
|
||||
projectName,
|
||||
onExit,
|
||||
menu,
|
||||
orphaned = false,
|
||||
className,
|
||||
}: AgentProjectHeaderProps): React.ReactElement => (
|
||||
<div className={cn("tw-flex tw-w-full tw-items-center tw-gap-1 tw-px-2 tw-py-1.5", className)}>
|
||||
<Button
|
||||
variant="ghost2"
|
||||
size="sm"
|
||||
onClick={onExit}
|
||||
aria-label="Leave project"
|
||||
title="Leave project"
|
||||
className="tw-flex tw-shrink-0 tw-items-center tw-px-1.5 tw-text-muted hover:tw-text-normal"
|
||||
>
|
||||
<ChevronLeft className="tw-size-4" />
|
||||
</Button>
|
||||
|
||||
{orphaned ? (
|
||||
<span className="tw-min-w-0 tw-flex-1 tw-text-ui-small tw-text-muted">
|
||||
This project no longer exists
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<ProjectIconTile id={projectId} />
|
||||
<TruncatedText
|
||||
className="tw-min-w-0 tw-flex-1 tw-text-ui-small tw-font-medium tw-text-normal"
|
||||
tooltipContent={projectName}
|
||||
>
|
||||
{projectName}
|
||||
</TruncatedText>
|
||||
|
||||
{menu}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
AgentProjectHeader.displayName = "AgentProjectHeader";
|
||||
160
src/agentMode/ui/AgentProjectRowActions.tsx
Normal file
160
src/agentMode/ui/AgentProjectRowActions.tsx
Normal file
|
|
@ -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 (
|
||||
<div className={cn("tw-flex tw-items-center tw-gap-1.5", className)}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`Reveal ${project.name} in vault`}
|
||||
title="Reveal in vault"
|
||||
className="tw-size-5 tw-p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
revealProjectFolder(app, project);
|
||||
}}
|
||||
>
|
||||
<FolderSearch className="tw-size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`Edit project ${project.name}`}
|
||||
title="Edit project"
|
||||
className="tw-size-5 tw-p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit();
|
||||
}}
|
||||
>
|
||||
<Pencil className="tw-size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`Delete project ${project.name}`}
|
||||
title="Delete"
|
||||
className="tw-size-5 tw-p-0 tw-text-error hover:tw-text-error"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="tw-size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
AgentProjectRowActions.displayName = "AgentProjectRowActions";
|
||||
|
|
@ -126,7 +126,10 @@ export const AgentTabStrip: React.FC<Props> = ({ 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<string | null>(null);
|
||||
|
|
|
|||
|
|
@ -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<PlanPillProps> = ({ entries }) => (
|
||||
<div className="tw-my-1 tw-rounded tw-border tw-border-border tw-bg-secondary tw-px-2 tw-py-1">
|
||||
<p className="tw-mb-1 tw-text-xs tw-text-muted">Plan</p>
|
||||
<ul className="tw-flex tw-flex-col tw-gap-0.5 tw-text-sm">
|
||||
{entries.map((e, i) => (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- plan entries are positional and may share content
|
||||
<li key={`plan-${i}`} className="tw-flex tw-items-start tw-gap-2">
|
||||
<span aria-hidden="true">{planEntryIcon(e.status)}</span>
|
||||
<span className={planEntryClass(e.status)}>{e.content}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
// 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<PlanPillProps> = ({ entries }) =>
|
||||
entries.length === 0 ? null : (
|
||||
<div className="tw-my-1 tw-rounded tw-border tw-border-border tw-bg-secondary tw-px-2 tw-py-1">
|
||||
<p className="tw-mb-1 tw-text-xs tw-text-muted">Plan</p>
|
||||
<ul className="tw-flex tw-flex-col tw-gap-0.5 tw-text-sm">
|
||||
{entries.map((e, i) => (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- plan entries are positional and may share content
|
||||
<li key={`plan-${i}`} className="tw-flex tw-items-start tw-gap-2">
|
||||
<span aria-hidden="true">{planEntryIcon(e.status)}</span>
|
||||
<span className={planEntryClass(e.status)}>{e.content}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AgentTrail;
|
||||
|
|
|
|||
25
src/agentMode/ui/AgentWelcomeCard.test.tsx
Normal file
25
src/agentMode/ui/AgentWelcomeCard.test.tsx
Normal file
|
|
@ -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(<AgentWelcomeCard onCreate={jest.fn()} onDismiss={jest.fn()} />);
|
||||
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(<AgentWelcomeCard onCreate={onCreate} onDismiss={jest.fn()} />);
|
||||
fireEvent.click(screen.getByText("New project"));
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("invokes onDismiss from the × control", () => {
|
||||
const onDismiss = jest.fn();
|
||||
render(<AgentWelcomeCard onCreate={jest.fn()} onDismiss={onDismiss} />);
|
||||
fireEvent.click(screen.getByLabelText("Dismiss"));
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
70
src/agentMode/ui/AgentWelcomeCard.tsx
Normal file
70
src/agentMode/ui/AgentWelcomeCard.tsx
Normal file
|
|
@ -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 => (
|
||||
<div
|
||||
className={cn(
|
||||
"tw-relative tw-flex tw-flex-col tw-gap-2 tw-rounded-md tw-border tw-border-solid tw-border-border tw-bg-secondary tw-p-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost2"
|
||||
size="icon"
|
||||
onClick={onDismiss}
|
||||
aria-label="Dismiss"
|
||||
// Pull into the padding so the × hugs the corner without crowding the title.
|
||||
className="tw-absolute tw-right-1 tw-top-1 tw-size-6 tw-text-muted hover:tw-text-normal"
|
||||
>
|
||||
<X className="tw-size-4" />
|
||||
</Button>
|
||||
|
||||
<div className="tw-flex tw-items-center tw-gap-1.5 tw-pr-6 tw-text-ui-small tw-font-semibold tw-text-normal">
|
||||
<Sparkles className="tw-size-4 tw-shrink-0 tw-text-accent" />
|
||||
<span>Try a project</span>
|
||||
</div>
|
||||
|
||||
<p className="tw-m-0 tw-text-ui-smaller tw-text-muted">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={(e) => onCreate(e.currentTarget)}
|
||||
className="tw-mt-1 tw-w-full tw-gap-2"
|
||||
>
|
||||
<FolderPlus className="tw-size-4" />
|
||||
New project
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
AgentWelcomeCard.displayName = "AgentWelcomeCard";
|
||||
137
src/agentMode/ui/CreateProjectPanel.tsx
Normal file
137
src/agentMode/ui/CreateProjectPanel.tsx
Normal file
|
|
@ -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<void>;
|
||||
}
|
||||
|
||||
/** 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<HTMLDivElement>(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<PanelPosition>(() => 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(
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-label="New project"
|
||||
className="tw-fixed tw-z-popover tw-w-[260px] tw-rounded-md tw-border tw-border-solid tw-border-border tw-bg-primary tw-p-3 tw-shadow-lg"
|
||||
// Dynamic pixel position (computed from the anchor) — inline style is the
|
||||
// established pattern for this in `command-ui/draggable-modal.tsx`.
|
||||
style={{ top: position.top, left: position.left }}
|
||||
>
|
||||
<AgentProjectCreateForm
|
||||
title="New project"
|
||||
subtitle="Create a new project in your vault"
|
||||
onSave={onSave}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
</div>,
|
||||
doc.body
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateProjectPanel;
|
||||
112
src/agentMode/ui/GlobalRecentChatsSection.test.tsx
Normal file
112
src/agentMode/ui/GlobalRecentChatsSection.test.tsx
Normal file
|
|
@ -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<React.ComponentProps<typeof GlobalRecentChatsSection>> = {}) {
|
||||
return render(
|
||||
<GlobalRecentChatsSection
|
||||
items={props.items ?? []}
|
||||
variant={props.variant}
|
||||
title={props.title}
|
||||
runningChatIds={props.runningChatIds}
|
||||
attentionChatIds={props.attentionChatIds}
|
||||
onLoadChat={noop}
|
||||
onUpdateTitle={noop}
|
||||
onDeleteChat={noop}
|
||||
onOpenSourceFile={noop}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<typeof GlobalRecentChatsSection>["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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<void>;
|
||||
onUpdateTitle: (id: string, newTitle: string) => Promise<void>;
|
||||
|
|
@ -25,6 +63,18 @@ interface GlobalRecentChatsSectionProps {
|
|||
onOpenSourceFile: (id: string) => Promise<void>;
|
||||
/** 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<string>;
|
||||
/**
|
||||
* 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<string>;
|
||||
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 }> }) => (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="tw-flex tw-size-6 tw-shrink-0 tw-items-center tw-justify-center tw-rounded-md tw-bg-secondary tw-text-muted"
|
||||
>
|
||||
<Icon className="tw-size-4" />
|
||||
</span>
|
||||
));
|
||||
const ChatIconTile = memo(
|
||||
({
|
||||
Icon,
|
||||
needsAttention,
|
||||
}: {
|
||||
Icon: React.ComponentType<{ className?: string }>;
|
||||
needsAttention?: boolean;
|
||||
}) => (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="tw-flex tw-size-6 tw-shrink-0 tw-items-center tw-justify-center tw-rounded-md tw-bg-secondary tw-text-muted"
|
||||
>
|
||||
<ChatIconWithAttention
|
||||
icon={Icon}
|
||||
needsAttention={needsAttention}
|
||||
iconClassName="tw-size-4"
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
);
|
||||
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 (
|
||||
<div className="tw-flex tw-min-h-9 tw-items-center tw-gap-2 tw-rounded-md tw-px-2 tw-py-1.5">
|
||||
<ChatIconTile Icon={Icon} />
|
||||
<ChatIconTile Icon={Icon} needsAttention={hasAttention} />
|
||||
<Input
|
||||
value={editingTitle}
|
||||
onChange={(e) => onEditingTitleChange(e.target.value)}
|
||||
|
|
@ -152,7 +223,7 @@ const RecentChatRow = memo(function RecentChatRow({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<ChatIconTile Icon={Icon} />
|
||||
<ChatIconTile Icon={Icon} needsAttention={hasAttention} />
|
||||
<span
|
||||
className="tw-min-w-0 tw-flex-1 tw-truncate tw-text-ui-small tw-text-normal"
|
||||
title={item.title}
|
||||
|
|
@ -160,17 +231,28 @@ const RecentChatRow = memo(function RecentChatRow({
|
|||
{item.title}
|
||||
</span>
|
||||
|
||||
{/* 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. */}
|
||||
<span
|
||||
className="tw-shrink-0 tw-whitespace-nowrap tw-text-xs tw-text-muted group-focus-within:tw-hidden group-hover:tw-hidden"
|
||||
title={new Date(item.lastAccessedAt).toLocaleString()}
|
||||
>
|
||||
{formatCompactRelativeTime(item.lastAccessedAt.getTime())}
|
||||
</span>
|
||||
{isRunning ? (
|
||||
<LoaderCircle
|
||||
className={cn(
|
||||
"tw-size-3.5 tw-shrink-0 tw-animate-spin tw-text-accent",
|
||||
"group-focus-within:tw-hidden group-hover:tw-hidden"
|
||||
)}
|
||||
aria-label="Running"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="tw-shrink-0 tw-whitespace-nowrap tw-text-xs tw-text-muted group-focus-within:tw-hidden group-hover:tw-hidden"
|
||||
title={new Date(item.lastAccessedAt).toLocaleString()}
|
||||
>
|
||||
{formatCompactRelativeTime(item.lastAccessedAt.getTime())}
|
||||
</span>
|
||||
)}
|
||||
<div className="tw-hidden tw-shrink-0 tw-items-center tw-gap-1.5 group-focus-within:tw-flex group-hover:tw-flex">
|
||||
{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 (
|
||||
<div className={cn("tw-flex tw-flex-col tw-gap-2", className)}>
|
||||
<div
|
||||
role={title ? "group" : undefined}
|
||||
aria-label={title}
|
||||
// tw-grow fills the shelf panel's fixed floor (AgentHomeShelf) so the
|
||||
// empty / no-match copy below can center inside the card instead of
|
||||
// hugging the top of a mostly blank panel.
|
||||
className={cn("tw-flex tw-grow tw-flex-col tw-gap-2", className)}
|
||||
>
|
||||
{items.length > 0 && (
|
||||
<div className="tw-p-1">
|
||||
<SearchBar value={query} onChange={setQuery} placeholder="Search chats..." />
|
||||
{/* Compact height so the search row reads as a list utility, not a
|
||||
full-size form field towering over the 36px rows below. */}
|
||||
<SearchBar
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Search chats..."
|
||||
inputClassName="!tw-h-7"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="tw-px-2 tw-py-1.5 tw-text-xs tw-text-muted">
|
||||
{items.length === 0 ? "No recent chats" : "No matching chats"}
|
||||
<div className="tw-flex tw-flex-1 tw-items-center tw-justify-center tw-px-2 tw-py-1.5 tw-text-xs tw-text-muted">
|
||||
{items.length > 0
|
||||
? "No matching chats"
|
||||
: variant === "project"
|
||||
? "No chats in this project yet"
|
||||
: "No recent chats"}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="tw-max-h-80 tw-overflow-y-auto">
|
||||
<div className="tw-flex tw-flex-col tw-divide-y tw-divide-border">
|
||||
{filteredItems.map((item) => (
|
||||
<RecentChatRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
isEditing={editingId === item.id}
|
||||
editingTitle={editingTitle}
|
||||
confirmingDelete={confirmDeleteId === item.id}
|
||||
onOpen={onLoadChat}
|
||||
onStartEdit={handleStartEdit}
|
||||
onEditingTitleChange={setEditingTitle}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
onCancelEdit={() => setEditingId(null)}
|
||||
onStartDelete={setConfirmDeleteId}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCancelDelete={() => setConfirmDeleteId(null)}
|
||||
canOpenSourceFile={!isNativeChatId(item.id)}
|
||||
onOpenSourceFile={handleOpenSourceFile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
// 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.
|
||||
<div className="tw-flex tw-flex-col tw-divide-y tw-divide-border">
|
||||
{/* 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. */}
|
||||
<ScrollArea className="tw-max-h-56 tw-overflow-y-auto">
|
||||
<div className="tw-flex tw-flex-col tw-divide-y tw-divide-border">
|
||||
{visibleItems.map((item) => (
|
||||
<RecentChatRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
isEditing={editingId === item.id}
|
||||
// Only the row being renamed needs the live draft; passing a
|
||||
// stable "" to the rest keeps their memo from re-rendering on
|
||||
// every keystroke.
|
||||
editingTitle={editingId === item.id ? editingTitle : ""}
|
||||
confirmingDelete={confirmDeleteId === item.id}
|
||||
onOpen={onLoadChat}
|
||||
onStartEdit={handleStartEdit}
|
||||
onEditingTitleChange={setEditingTitle}
|
||||
// Only the editing row needs the live save handler (it changes
|
||||
// per keystroke via editingTitle); the rest get a stable noop so
|
||||
// their memo isn't defeated mid-rename.
|
||||
onSaveEdit={editingId === item.id ? handleSaveEdit : NOOP_SAVE}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onStartDelete={setConfirmDeleteId}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCancelDelete={handleCancelDelete}
|
||||
canOpenSourceFile={!isNativeChatId(item.id)}
|
||||
onOpenSourceFile={handleOpenSourceFile}
|
||||
isRunning={runningChatIds?.has(item.id) ?? false}
|
||||
hasAttention={!!item.needsAttention || (attentionChatIds?.has(item.id) ?? false)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{hasOverflow && (
|
||||
<ChatHistoryPopover
|
||||
chatHistory={items}
|
||||
onUpdateTitle={onUpdateTitle}
|
||||
onDeleteChat={onDeleteChat}
|
||||
onLoadChat={onLoadChat}
|
||||
onOpenSourceFile={onOpenSourceFile}
|
||||
getIcon={resolveChatIcon}
|
||||
// Full-width row near the pane's lower half: open downward like
|
||||
// an accordion. Radix flips to "top" if the area below is tight.
|
||||
side="bottom"
|
||||
align="start"
|
||||
>
|
||||
{/* 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. */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"tw-flex tw-cursor-pointer tw-items-center tw-justify-between tw-rounded-md tw-px-2 tw-py-1.5",
|
||||
"tw-text-xs tw-text-accent tw-transition-colors hover:tw-bg-modifier-hover hover:tw-text-accent-hover"
|
||||
)}
|
||||
onClick={() => onLoadHistory?.()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.currentTarget.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>View all chats</span>
|
||||
<ChevronRight className="tw-size-3 tw-shrink-0" />
|
||||
</div>
|
||||
</ChatHistoryPopover>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
52
src/agentMode/ui/ProjectIconTile.tsx
Normal file
52
src/agentMode/ui/ProjectIconTile.tsx
Normal file
|
|
@ -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 => (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"tw-flex tw-size-6 tw-shrink-0 tw-items-center tw-justify-center tw-rounded-md",
|
||||
projectIconClasses(id)
|
||||
)}
|
||||
>
|
||||
<Folder className="tw-size-4" />
|
||||
</span>
|
||||
)
|
||||
);
|
||||
ProjectIconTile.displayName = "ProjectIconTile";
|
||||
166
src/agentMode/ui/ProjectInfoPopover.test.tsx
Normal file
166
src/agentMode/ui/ProjectInfoPopover.test.tsx
Normal file
|
|
@ -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 =
|
||||
"<!-- copilot:generated-agents-mirror v1 — DO NOT EDIT. Mirror of this project's instructions " +
|
||||
"(project.md); regenerated each session. Delete this line to take over the file. -->";
|
||||
|
||||
function renderPopover(
|
||||
todoList: AgentTodoListEntry[] | null,
|
||||
folderNames: string[] = [],
|
||||
fileContents: Record<string, string> = {}
|
||||
) {
|
||||
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<typeof ProjectInfoPopover>[0]["app"];
|
||||
render(<ProjectInfoPopover app={app} project={PROJECT} todoList={todoList} />);
|
||||
// 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<typeof ProjectInfoPopover>[0]["app"];
|
||||
render(<ProjectInfoPopover app={app} project={PROJECT} todoList={null} />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
385
src/agentMode/ui/ProjectInfoPopover.tsx
Normal file
385
src/agentMode/ui/ProjectInfoPopover.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||
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 (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"tw-flex tw-h-4 tw-w-7 tw-shrink-0 tw-items-center tw-justify-center tw-rounded tw-text-smallest tw-font-bold tw-uppercase",
|
||||
BADGE_CLASSES[ext] ?? "tw-bg-secondary tw-text-muted"
|
||||
)}
|
||||
>
|
||||
{ext.slice(0, 4) || "file"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="tw-px-3 tw-py-2.5">
|
||||
<div className="tw-mb-1.5 tw-flex tw-items-baseline tw-justify-between">
|
||||
<span className="tw-text-ui-smaller tw-font-semibold tw-text-faint">Progress</span>
|
||||
<span className="tw-text-ui-smaller tw-text-faint">
|
||||
{done}/{todoList.length}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="tw-m-0 tw-flex tw-list-none tw-flex-col tw-gap-1 tw-p-0">
|
||||
{todoList.map((todo, i) => (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- entries are positional and may share content
|
||||
<li key={`todo-${i}`} className="tw-flex tw-items-center tw-gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"tw-flex tw-size-5 tw-shrink-0 tw-items-center tw-justify-center tw-rounded-full tw-text-xs tw-font-semibold",
|
||||
todo.status === "completed"
|
||||
? "tw-bg-interactive-accent tw-text-on-accent"
|
||||
: todo.status === "in_progress"
|
||||
? "tw-border-[1.5px] tw-border-solid tw-border-interactive-accent tw-text-accent"
|
||||
: "tw-border-[1.5px] tw-border-solid tw-border-border tw-text-faint"
|
||||
)}
|
||||
>
|
||||
{todo.status === "completed" ? <Check className="tw-size-3" /> : i + 1}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"tw-min-w-0 tw-truncate tw-text-ui-small",
|
||||
// Done is de-emphasized (faint + strikethrough); everything not
|
||||
// yet done — in_progress AND pending — stays full-strength so the
|
||||
// contrast reads "completed vs remaining" at a glance.
|
||||
todo.status === "completed" ? "tw-text-faint tw-line-through" : "tw-text-normal"
|
||||
)}
|
||||
title={todo.content}
|
||||
>
|
||||
{todo.content}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<TFile[]>([]);
|
||||
|
||||
// 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 (
|
||||
<div className="tw-px-3 tw-py-2.5">
|
||||
<div className="tw-mb-1 tw-flex tw-items-center tw-justify-between">
|
||||
<span className="tw-text-ui-smaller tw-font-semibold tw-text-faint">Project files</span>
|
||||
<Button
|
||||
variant="ghost2"
|
||||
size="icon"
|
||||
aria-label="Reveal project files in vault"
|
||||
className="tw-size-5 tw-text-faint hover:tw-text-normal"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
revealProjectFolder(app, project);
|
||||
}}
|
||||
>
|
||||
<FolderSearch className="tw-size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Fixed first row: the project's instructions. Shows ONLY the label —
|
||||
never the backing file name (project.md / the AGENTS.md mirror). */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="tw-flex tw-cursor-pointer tw-items-center tw-gap-2 tw-rounded tw-p-1 hover:tw-bg-secondary"
|
||||
onClick={handleEditSystemPrompt}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") handleEditSystemPrompt();
|
||||
}}
|
||||
>
|
||||
<FileBadge ext="md" />
|
||||
<span className="tw-min-w-0 tw-flex-1 tw-truncate tw-text-ui-small">System Prompt</span>
|
||||
<SquarePen aria-hidden="true" className="tw-size-3.5 tw-shrink-0 tw-text-faint" />
|
||||
</div>
|
||||
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.path}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="tw-flex tw-cursor-pointer tw-items-center tw-gap-2 tw-rounded tw-p-1 hover:tw-bg-secondary"
|
||||
onClick={() => handleOpenFile(file)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") handleOpenFile(file);
|
||||
}}
|
||||
>
|
||||
<FileBadge ext={file.extension.toLowerCase()} />
|
||||
<span className="tw-min-w-0 tw-flex-1 tw-truncate tw-text-ui-small" title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 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. */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="tw-flex tw-cursor-pointer tw-items-center tw-gap-1 tw-rounded tw-p-1 tw-text-muted hover:tw-bg-secondary"
|
||||
onClick={() => setOutputsOpen((v) => !v)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") setOutputsOpen((v) => !v);
|
||||
}}
|
||||
aria-expanded={outputsOpen}
|
||||
>
|
||||
{outputsOpen ? (
|
||||
<ChevronDown className="tw-size-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronRight className="tw-size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
<span className="tw-text-ui-small tw-font-medium">Outputs</span>
|
||||
<span className="tw-text-ui-smaller tw-text-faint">(0)</span>
|
||||
</div>
|
||||
{outputsOpen && (
|
||||
<div className="tw-py-1 tw-pl-6 tw-text-ui-smaller tw-text-faint">No outputs yet</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost2"
|
||||
size="icon"
|
||||
aria-label={`Project info for ${project.name}`}
|
||||
className={cn("tw-size-7 tw-text-muted hover:tw-text-normal", className)}
|
||||
>
|
||||
<List className="tw-size-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
container={container}
|
||||
className="tw-w-72 tw-p-0"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="tw-flex tw-items-center tw-gap-1 tw-px-3 tw-py-2.5">
|
||||
<span
|
||||
className="tw-min-w-0 tw-flex-1 tw-truncate tw-text-ui-small tw-font-semibold"
|
||||
title={project.name}
|
||||
>
|
||||
{project.name}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost2"
|
||||
size="icon"
|
||||
aria-label={`Edit project ${project.name}`}
|
||||
className="tw-size-6 tw-text-muted hover:tw-text-normal"
|
||||
onClick={handleEdit}
|
||||
>
|
||||
<Settings className="tw-size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost2"
|
||||
size="icon"
|
||||
aria-label="Reveal project folder in vault"
|
||||
className="tw-size-6 tw-text-muted hover:tw-text-normal"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
revealProjectFolder(app, project);
|
||||
}}
|
||||
>
|
||||
<FolderSearch className="tw-size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<ProgressSection todoList={todoList} />
|
||||
<ProjectFilesSection
|
||||
app={app}
|
||||
project={project}
|
||||
onClose={() => setOpen(false)}
|
||||
onEdited={onEdited}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ProjectInfoPopover.displayName = "ProjectInfoPopover";
|
||||
104
src/agentMode/ui/ProjectPickerList.test.tsx
Normal file
104
src/agentMode/ui/ProjectPickerList.test.tsx
Normal file
|
|
@ -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<HTMLElement>('[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<string>) {
|
||||
return render(
|
||||
<ProjectPickerList
|
||||
projects={[projectA, projectB, projectC]}
|
||||
onSelect={noop}
|
||||
app={app}
|
||||
projectUsageTimestampsManager={manager}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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<string>();
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }) => (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"tw-flex tw-size-6 tw-shrink-0 tw-items-center tw-justify-center tw-rounded-md",
|
||||
projectIconClasses(id)
|
||||
)}
|
||||
>
|
||||
<Folder className="tw-size-4" />
|
||||
</span>
|
||||
));
|
||||
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<string>;
|
||||
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<string> | 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<string> | 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) => (
|
||||
<AgentHomeListRow
|
||||
label={project.name}
|
||||
timeMs={project.UsageTimestamps || project.created}
|
||||
timeMs={timeMs}
|
||||
onClick={() => onSelect(project)}
|
||||
leading={<ProjectIconTile id={project.id} />}
|
||||
trailing={<AgentProjectRowActions app={app} project={project} onDeleted={onDeleted} />}
|
||||
/>
|
||||
));
|
||||
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 (
|
||||
<div className={cn("tw-flex tw-flex-col tw-divide-y tw-divide-border", className)}>
|
||||
// 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.
|
||||
<div className={cn("tw-flex tw-grow tw-flex-col tw-divide-y tw-divide-border", className)}>
|
||||
{onCreate && <AgentHomeCreateRow label="New project" onClick={onCreate} />}
|
||||
{total === 0 ? (
|
||||
<div className="tw-px-2 tw-py-1.5 tw-text-xs tw-text-muted">No projects available</div>
|
||||
<div className="tw-flex tw-flex-1 tw-items-center tw-justify-center tw-px-2 tw-py-1.5 tw-text-xs tw-text-muted">
|
||||
No projects available
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{inlineProjects.map((project) => (
|
||||
<ProjectRow key={project.id} project={project} onSelect={onSelect} />
|
||||
<ProjectRow
|
||||
key={project.id}
|
||||
project={project}
|
||||
timeMs={effectiveLastUsedMs(project, projectUsageTimestampsManager)}
|
||||
onSelect={onSelect}
|
||||
app={app}
|
||||
onDeleted={onProjectDeleted}
|
||||
/>
|
||||
))}
|
||||
{hasOverflow && (
|
||||
<AgentHomeViewAll
|
||||
|
|
@ -152,10 +173,13 @@ export const ProjectPickerList = memo(
|
|||
<ProjectRow
|
||||
key={project.id}
|
||||
project={project}
|
||||
timeMs={effectiveLastUsedMs(project, projectUsageTimestampsManager)}
|
||||
onSelect={(selected) => {
|
||||
onSelect(selected);
|
||||
close();
|
||||
}}
|
||||
app={app}
|
||||
onDeleted={onProjectDeleted}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
106
src/agentMode/ui/ProjectSystemPromptModal.tsx
Normal file
106
src/agentMode/ui/ProjectSystemPromptModal.tsx
Normal file
|
|
@ -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<void>;
|
||||
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 (
|
||||
<div className="tw-flex tw-flex-col tw-gap-3">
|
||||
<Textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
autoFocus
|
||||
rows={6}
|
||||
className="tw-min-h-24 tw-text-ui-small"
|
||||
placeholder="Instructions the agent receives in every chat of this project…"
|
||||
/>
|
||||
<div className="tw-flex tw-justify-end tw-gap-2">
|
||||
<Button variant="secondary" onClick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="default" onClick={handleSave} disabled={saving}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight editor for a project's system prompt (the "System Prompt" row
|
||||
* in the project-info popover) — the F3 frame of the design handoff. A small
|
||||
* native Modal (popout-correct, ESC handling) instead of the full Edit
|
||||
* Project modal: a single textarea. No `{[[note]]}` template hints here —
|
||||
* those variables are expanded by legacy chat's prompt processor only; agent
|
||||
* backends receive the prompt verbatim.
|
||||
*
|
||||
* Saving persists via the caller; the prompt is captured per session at
|
||||
* create/resume time on every backend, so edits apply to NEW chats only —
|
||||
* the Notice says so explicitly.
|
||||
*/
|
||||
export class ProjectSystemPromptModal extends Modal {
|
||||
private root: Root | null = null;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
private readonly initialPrompt: string,
|
||||
private readonly persist: (prompt: string) => Promise<void>
|
||||
) {
|
||||
super(app);
|
||||
// https://docs.obsidian.md/Reference/TypeScript+API/Modal/setTitle
|
||||
// @ts-ignore
|
||||
this.setTitle("Edit System Prompt");
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
this.root = createPluginRoot(contentEl, this.app);
|
||||
this.root.render(
|
||||
<ProjectSystemPromptModalContent
|
||||
initialPrompt={this.initialPrompt}
|
||||
onCancel={() => this.close()}
|
||||
onSave={async (prompt) => {
|
||||
try {
|
||||
await this.persist(prompt);
|
||||
new Notice("System prompt saved. Applies to new chats.");
|
||||
this.close();
|
||||
} catch (e) {
|
||||
logError("[ProjectSystemPromptModal] save failed", e);
|
||||
new Notice("Failed to save the system prompt.");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
this.root?.unmount();
|
||||
this.root = null;
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
|
@ -631,7 +631,10 @@ describe("buildModelOnChange", () => {
|
|||
// Allow the IIFE to run.
|
||||
await new Promise((r) => window.setTimeout(r, 0));
|
||||
expect(persistDefaultSelection).not.toHaveBeenCalled();
|
||||
expect(createSession).toHaveBeenCalledWith("claude", { baseModelId: "opus", effort: "low" });
|
||||
expect(createSession).toHaveBeenCalledWith("claude", undefined, {
|
||||
baseModelId: "opus",
|
||||
effort: "low",
|
||||
});
|
||||
expect(setDefaultBackend).toHaveBeenCalledWith("claude");
|
||||
expect(closeSession).toHaveBeenCalledWith("tab-1");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -365,7 +365,8 @@ function runCrossBackendPick(
|
|||
): void {
|
||||
void (async () => {
|
||||
try {
|
||||
await manager.createSession(targetBackendId, { baseModelId, effort });
|
||||
// projectId left default (active scope); the transient pick only seeds the model.
|
||||
await manager.createSession(targetBackendId, undefined, { baseModelId, effort });
|
||||
manager.setDefaultBackend(targetBackendId);
|
||||
if (oldSessionId) {
|
||||
void manager
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { AgentChatBackend } from "@/agentMode/session/AgentChatBackend";
|
||||
import type {
|
||||
AgentChatMessage,
|
||||
AgentTodoListEntry,
|
||||
AskUserQuestionPrompt,
|
||||
CurrentPlan,
|
||||
PermissionPrompt,
|
||||
|
|
@ -13,6 +14,7 @@ interface FakeBackendState {
|
|||
isStarting: boolean;
|
||||
hasPendingPlanPermission: boolean;
|
||||
currentPlan: CurrentPlan | null;
|
||||
currentTodoList?: AgentTodoListEntry[] | null;
|
||||
pendingToolPermissions: PermissionPrompt[];
|
||||
pendingAskUserQuestions: AskUserQuestionPrompt[];
|
||||
}
|
||||
|
|
@ -28,6 +30,7 @@ function makeFakeBackend(initial: Partial<FakeBackendState> = {}) {
|
|||
isStarting: initial.isStarting ?? false,
|
||||
hasPendingPlanPermission: initial.hasPendingPlanPermission ?? false,
|
||||
currentPlan: initial.currentPlan ?? null,
|
||||
currentTodoList: initial.currentTodoList ?? null,
|
||||
pendingToolPermissions: initial.pendingToolPermissions ?? [],
|
||||
pendingAskUserQuestions: initial.pendingAskUserQuestions ?? [],
|
||||
};
|
||||
|
|
@ -42,6 +45,7 @@ function makeFakeBackend(initial: Partial<FakeBackendState> = {}) {
|
|||
isStarting: () => state.isStarting,
|
||||
hasPendingPlanPermission: () => state.hasPendingPlanPermission,
|
||||
getCurrentPlan: () => state.currentPlan,
|
||||
getCurrentTodoList: () => state.currentTodoList ?? null,
|
||||
getPendingToolPermissions: () => state.pendingToolPermissions,
|
||||
getPendingAskUserQuestions: () => state.pendingAskUserQuestions,
|
||||
} as unknown as AgentChatBackend;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { AgentChatBackend } from "@/agentMode/session/AgentChatBackend";
|
||||
import type {
|
||||
AgentChatMessage,
|
||||
AgentTodoListEntry,
|
||||
AskUserQuestionPrompt,
|
||||
CurrentPlan,
|
||||
PermissionPrompt,
|
||||
|
|
@ -19,6 +20,7 @@ export interface AgentChatRuntimeState {
|
|||
isStarting: boolean;
|
||||
hasPendingPlanPermission: boolean;
|
||||
currentPlan: CurrentPlan | null;
|
||||
currentTodoList: AgentTodoListEntry[] | null;
|
||||
pendingToolPermissions: PermissionPrompt[];
|
||||
pendingAskUserQuestions: AskUserQuestionPrompt[];
|
||||
}
|
||||
|
|
@ -32,6 +34,9 @@ export function useAgentChatRuntimeState(backend: AgentChatBackend): AgentChatRu
|
|||
const [currentPlan, setCurrentPlan] = useState<CurrentPlan | null>(() =>
|
||||
backend.getCurrentPlan()
|
||||
);
|
||||
const [currentTodoList, setCurrentTodoList] = useState<AgentTodoListEntry[] | null>(() =>
|
||||
backend.getCurrentTodoList()
|
||||
);
|
||||
const [pendingToolPermissions, setPendingToolPermissions] = useState<PermissionPrompt[]>(() =>
|
||||
backend.getPendingToolPermissions()
|
||||
);
|
||||
|
|
@ -59,6 +64,7 @@ export function useAgentChatRuntimeState(backend: AgentChatBackend): AgentChatRu
|
|||
setIsStarting(backend.isStarting());
|
||||
setHasPendingPlanPermission(backend.hasPendingPlanPermission());
|
||||
setCurrentPlan(backend.getCurrentPlan());
|
||||
setCurrentTodoList(backend.getCurrentTodoList());
|
||||
setPendingToolPermissions(backend.getPendingToolPermissions());
|
||||
setPendingAskUserQuestions(backend.getPendingAskUserQuestions());
|
||||
};
|
||||
|
|
@ -74,6 +80,7 @@ export function useAgentChatRuntimeState(backend: AgentChatBackend): AgentChatRu
|
|||
isStarting,
|
||||
hasPendingPlanPermission,
|
||||
currentPlan,
|
||||
currentTodoList,
|
||||
pendingToolPermissions,
|
||||
pendingAskUserQuestions,
|
||||
};
|
||||
|
|
|
|||
192
src/agentMode/ui/hooks/useAgentHistoryControls.test.tsx
Normal file
192
src/agentMode/ui/hooks/useAgentHistoryControls.test.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { useAgentHistoryControls } from "@/agentMode/ui/hooks/useAgentHistoryControls";
|
||||
import { GLOBAL_SCOPE } from "@/agentMode/session/scope";
|
||||
import type { AgentSessionManager } from "@/agentMode/session/AgentSessionManager";
|
||||
import type { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover";
|
||||
import type CopilotPlugin from "@/main";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
|
||||
const item = (id: string): ChatHistoryItem => ({ id }) as unknown as ChatHistoryItem;
|
||||
|
||||
function makeManager() {
|
||||
return {
|
||||
getChatHistoryItems: jest.fn(async () => [item("a"), item("b")]),
|
||||
updateChatTitle: jest.fn(async () => {}),
|
||||
deleteChatHistory: jest.fn(async () => {}),
|
||||
} as unknown as AgentSessionManager & {
|
||||
getChatHistoryItems: jest.Mock;
|
||||
updateChatTitle: jest.Mock;
|
||||
deleteChatHistory: jest.Mock;
|
||||
};
|
||||
}
|
||||
|
||||
const plugin = {} as unknown as CopilotPlugin;
|
||||
|
||||
const renderControls = (manager: AgentSessionManager, scope?: string) =>
|
||||
renderHook(({ s }: { s?: string }) => useAgentHistoryControls(manager, plugin, s), {
|
||||
initialProps: { s: scope },
|
||||
});
|
||||
|
||||
describe("useAgentHistoryControls scope", () => {
|
||||
it("regression: the global landing caller (no scope) loads ALL history", async () => {
|
||||
// Reason: the highest-risk regression in PR2a is silently scoping the global
|
||||
// Recent Chats list. Omitting `scope` must keep fetching every chat — the
|
||||
// manager treats `undefined` as the flat all-chats view.
|
||||
const manager = makeManager() as AgentSessionManager & { getChatHistoryItems: jest.Mock };
|
||||
const { result } = renderControls(manager);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadChatHistory();
|
||||
});
|
||||
|
||||
expect(manager.getChatHistoryItems).toHaveBeenCalledWith(undefined);
|
||||
expect(result.current.chatHistoryItems).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("loads only the active scope's chats when a project scope is passed", async () => {
|
||||
const manager = makeManager() as AgentSessionManager & { getChatHistoryItems: jest.Mock };
|
||||
const { result } = renderControls(manager, "project-1");
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadChatHistory();
|
||||
});
|
||||
|
||||
expect(manager.getChatHistoryItems).toHaveBeenCalledWith("project-1");
|
||||
});
|
||||
|
||||
it("GLOBAL_SCOPE behaves like the global all-chats view", async () => {
|
||||
const manager = makeManager() as AgentSessionManager & { getChatHistoryItems: jest.Mock };
|
||||
const { result } = renderControls(manager, GLOBAL_SCOPE);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadChatHistory();
|
||||
});
|
||||
|
||||
expect(manager.getChatHistoryItems).toHaveBeenCalledWith(GLOBAL_SCOPE);
|
||||
});
|
||||
|
||||
it("hides the previous scope's items on a scope change until the refetch lands", async () => {
|
||||
// Reason: AgentHome feeds one shared hook to both the project-landing Project
|
||||
// Chats and the conversation History popover. The `scope` prop flips
|
||||
// synchronously but the reload is async, so the stored items briefly belong to
|
||||
// the old scope — they must not flash (e.g. another project's chats, or the
|
||||
// global flat view) before the scoped refetch completes.
|
||||
const manager = makeManager() as AgentSessionManager & { getChatHistoryItems: jest.Mock };
|
||||
const { result, rerender } = renderControls(manager, "project-1");
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadChatHistory();
|
||||
});
|
||||
expect(result.current.chatHistoryItems).toHaveLength(2);
|
||||
|
||||
// Switch project before the new scope's items load → list clears, not stale.
|
||||
rerender({ s: "project-2" });
|
||||
expect(result.current.chatHistoryItems).toHaveLength(0);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadChatHistory();
|
||||
});
|
||||
expect(manager.getChatHistoryItems).toHaveBeenLastCalledWith("project-2");
|
||||
expect(result.current.chatHistoryItems).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("drops a stale out-of-order load whose scope was superseded mid-flight", async () => {
|
||||
// Reason: if the scope flips while an older fetch is still in flight and that
|
||||
// older fetch resolves AFTER the newer one, it must not write its stale items
|
||||
// back — otherwise the current scope's list is clobbered by the prior scope's
|
||||
// and sticks (the visible-scope guard would then blank it permanently).
|
||||
const resolvers: Record<string, (items: ChatHistoryItem[]) => void> = {};
|
||||
const manager = {
|
||||
getChatHistoryItems: jest.fn(
|
||||
(s?: string) =>
|
||||
new Promise<ChatHistoryItem[]>((resolve) => {
|
||||
resolvers[s ?? GLOBAL_SCOPE] = resolve;
|
||||
})
|
||||
),
|
||||
updateChatTitle: jest.fn(async () => {}),
|
||||
deleteChatHistory: jest.fn(async () => {}),
|
||||
} as unknown as AgentSessionManager & { getChatHistoryItems: jest.Mock };
|
||||
|
||||
const { result, rerender } = renderControls(manager, "project-1");
|
||||
|
||||
// Start project-1's load (call A) and leave it pending.
|
||||
let loadA!: Promise<void>;
|
||||
act(() => {
|
||||
loadA = result.current.loadChatHistory();
|
||||
});
|
||||
|
||||
// Scope flips to project-2; start its load (call B).
|
||||
rerender({ s: "project-2" });
|
||||
let loadB!: Promise<void>;
|
||||
act(() => {
|
||||
loadB = result.current.loadChatHistory();
|
||||
});
|
||||
|
||||
// B lands first → project-2 items are shown.
|
||||
await act(async () => {
|
||||
resolvers["project-2"]([item("b1"), item("b2")]);
|
||||
await loadB;
|
||||
});
|
||||
expect(result.current.chatHistoryItems).toHaveLength(2);
|
||||
|
||||
// A (stale project-1) resolves late → dropped, list stays on project-2's items.
|
||||
await act(async () => {
|
||||
resolvers["project-1"]([item("a1")]);
|
||||
await loadA;
|
||||
});
|
||||
expect(result.current.chatHistoryItems).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("reports settled only after a load for the CURRENT scope completes", async () => {
|
||||
// Reason: the project landing decides its layout (standalone Context vs the
|
||||
// tabbed shelf) from the chat count, so it must be able to tell "not loaded
|
||||
// yet" apart from "this project has no chats" — and a scope switch must
|
||||
// reset the flag until the new scope's load lands.
|
||||
const manager = makeManager() as AgentSessionManager & { getChatHistoryItems: jest.Mock };
|
||||
const { result, rerender } = renderControls(manager, "project-1");
|
||||
|
||||
expect(result.current.chatHistorySettled).toBe(false);
|
||||
await act(async () => {
|
||||
await result.current.loadChatHistory();
|
||||
});
|
||||
expect(result.current.chatHistorySettled).toBe(true);
|
||||
|
||||
rerender({ s: "project-2" });
|
||||
expect(result.current.chatHistorySettled).toBe(false);
|
||||
await act(async () => {
|
||||
await result.current.loadChatHistory();
|
||||
});
|
||||
expect(result.current.chatHistorySettled).toBe(true);
|
||||
});
|
||||
|
||||
it("settles even when the load fails, leaving the items empty", async () => {
|
||||
// Reason: a failed first load must not leave the landing undecided forever
|
||||
// (blank below the composer) — it settles with an empty list and the landing
|
||||
// degrades to its zero-chat layout.
|
||||
const manager = makeManager() as AgentSessionManager & { getChatHistoryItems: jest.Mock };
|
||||
manager.getChatHistoryItems.mockRejectedValueOnce(new Error("vault read failed"));
|
||||
const { result } = renderControls(manager, "project-1");
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadChatHistory();
|
||||
});
|
||||
|
||||
expect(result.current.chatHistorySettled).toBe(true);
|
||||
expect(result.current.chatHistoryItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refreshes within the same scope after a delete", async () => {
|
||||
const manager = makeManager() as AgentSessionManager & {
|
||||
getChatHistoryItems: jest.Mock;
|
||||
deleteChatHistory: jest.Mock;
|
||||
};
|
||||
const { result } = renderControls(manager, "project-1");
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteChat("a");
|
||||
});
|
||||
|
||||
expect(manager.deleteChatHistory).toHaveBeenCalledWith("a");
|
||||
// The post-mutation reload must stay scoped, not fall back to global.
|
||||
expect(manager.getChatHistoryItems).toHaveBeenCalledWith("project-1");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +1,26 @@
|
|||
import type { AgentSessionManager } from "@/agentMode/session/AgentSessionManager";
|
||||
import { GLOBAL_SCOPE, type ProjectScopeId } from "@/agentMode/session/scope";
|
||||
import type { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover";
|
||||
import { logError } from "@/logger";
|
||||
import type CopilotPlugin from "@/main";
|
||||
import { Notice } from "obsidian";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
// Frozen empty slice returned while the loaded items belong to a different scope
|
||||
// than the one currently requested — a stable reference avoids a render churn.
|
||||
const EMPTY_CHAT_HISTORY_ITEMS = Object.freeze([]) as unknown as ChatHistoryItem[];
|
||||
|
||||
export interface AgentHistoryControls {
|
||||
chatHistoryItems: ChatHistoryItem[];
|
||||
/**
|
||||
* True once a history load for the CURRENT scope has settled — success or
|
||||
* failure. Lets the project landing wait for a real answer before deciding
|
||||
* what to render below the composer (anti-flash), instead of mistaking the
|
||||
* not-yet-loaded empty list for "this project has no chats". On failure the
|
||||
* list stays empty and this still flips true: the landing degrades to its
|
||||
* zero-chat layout rather than a permanent blank.
|
||||
*/
|
||||
chatHistorySettled: boolean;
|
||||
loadChatHistory: () => Promise<void>;
|
||||
loadChat: (id: string) => Promise<void>;
|
||||
updateChatTitle: (id: string, newTitle: string) => Promise<void>;
|
||||
|
|
@ -18,12 +32,32 @@ export interface AgentHistoryControls {
|
|||
* History popover handlers (load list, open, rename, delete, open source) plus
|
||||
* the items state they refresh. Kept separate from chat-runtime state because
|
||||
* these are user-initiated one-shots, not backend-stream reactions.
|
||||
*
|
||||
* `scope` selects which chats the list loads (and reloads after rename/delete):
|
||||
* a project id shows only that project's chats, the global workspace (default)
|
||||
* shows the flat all-chats view. Changing `scope` re-fetches, so switching
|
||||
* project re-scopes the section.
|
||||
*/
|
||||
export function useAgentHistoryControls(
|
||||
manager: AgentSessionManager,
|
||||
plugin: CopilotPlugin
|
||||
plugin: CopilotPlugin,
|
||||
scope?: ProjectScopeId
|
||||
): AgentHistoryControls {
|
||||
const [chatHistoryItems, setChatHistoryItems] = useState<ChatHistoryItem[]>([]);
|
||||
// Track which scope the loaded items belong to. On a scope change the prop
|
||||
// updates synchronously but `loadChatHistory` is async, so the stored items
|
||||
// briefly belong to the *previous* scope — hide them until the refetch lands
|
||||
// rather than flash another project's (or the global flat) chats.
|
||||
const effectiveScope = scope ?? GLOBAL_SCOPE;
|
||||
const [loadedScope, setLoadedScope] = useState<ProjectScopeId>(effectiveScope);
|
||||
const visibleChatHistoryItems =
|
||||
loadedScope === effectiveScope ? chatHistoryItems : EMPTY_CHAT_HISTORY_ITEMS;
|
||||
// Which scope has had a load SETTLE (success or failure). Deliberately a
|
||||
// separate state from `loadedScope`: that one starts at `effectiveScope` (so
|
||||
// the initial empty list is "visible"), which would misreport "already
|
||||
// loaded" before the first fetch ever ran.
|
||||
const [settledScope, setSettledScope] = useState<ProjectScopeId | null>(null);
|
||||
const chatHistorySettled = settledScope === effectiveScope;
|
||||
|
||||
const isMountedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
|
|
@ -33,6 +67,15 @@ export function useAgentHistoryControls(
|
|||
};
|
||||
}, []);
|
||||
|
||||
// Latest requested scope, for dropping out-of-order loads: if the scope flips
|
||||
// while an older fetch is in flight, that fetch must NOT write back its (now
|
||||
// stale) items/scope after the newer fetch has already landed — otherwise the
|
||||
// current scope's list would be replaced by the previous scope's and stick.
|
||||
const latestScopeRef = useRef(effectiveScope);
|
||||
useEffect(() => {
|
||||
latestScopeRef.current = effectiveScope;
|
||||
}, [effectiveScope]);
|
||||
|
||||
// Wrap an async action so a failure logs and surfaces a Notice consistently.
|
||||
// `rethrow` is on for callbacks the popover uses to revert inline edits
|
||||
// (rename, delete) and off for fire-and-forget loads.
|
||||
|
|
@ -50,11 +93,21 @@ export function useAgentHistoryControls(
|
|||
);
|
||||
|
||||
const loadChatHistory = useCallback(async () => {
|
||||
const requestScope = scope ?? GLOBAL_SCOPE;
|
||||
await runWithNotice("load chat history", async () => {
|
||||
const items = await manager.getChatHistoryItems();
|
||||
if (isMountedRef.current) setChatHistoryItems(items);
|
||||
const items = await manager.getChatHistoryItems(scope);
|
||||
// Drop a stale result whose scope was superseded while it was in flight.
|
||||
if (isMountedRef.current && latestScopeRef.current === requestScope) {
|
||||
setLoadedScope(requestScope);
|
||||
setChatHistoryItems(items);
|
||||
}
|
||||
});
|
||||
}, [manager, runWithNotice]);
|
||||
// Mark settled even when the fetch failed (runWithNotice swallows the
|
||||
// error): the items just stay as they were. Same staleness guard as above.
|
||||
if (isMountedRef.current && latestScopeRef.current === requestScope) {
|
||||
setSettledScope(requestScope);
|
||||
}
|
||||
}, [manager, runWithNotice, scope]);
|
||||
|
||||
const loadChat = useCallback(
|
||||
async (id: string) => {
|
||||
|
|
@ -99,7 +152,8 @@ export function useAgentHistoryControls(
|
|||
);
|
||||
|
||||
return {
|
||||
chatHistoryItems,
|
||||
chatHistoryItems: visibleChatHistoryItems,
|
||||
chatHistorySettled,
|
||||
loadChatHistory,
|
||||
loadChat,
|
||||
updateChatTitle,
|
||||
|
|
|
|||
|
|
@ -134,4 +134,69 @@ describe("useAgentInputDrafts", () => {
|
|||
rerender({ activeSessionId: "a", liveSessionIds: ["b"], defaultIncludeActiveNote: false });
|
||||
expect(result.current.input).toBe("");
|
||||
});
|
||||
|
||||
it("migrates a draft typed during a session swap onto the replacement id", () => {
|
||||
// Simulates the empty-landing refresh: the user types while the old session
|
||||
// is still active (the new id isn't in liveSessionIds yet), then the swap
|
||||
// migrates that draft onto the new session before the old one is pruned.
|
||||
const { result, rerender } = renderDrafts({
|
||||
activeSessionId: "old",
|
||||
liveSessionIds: ["old"],
|
||||
defaultIncludeActiveNote: false,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setInput("typed during startup");
|
||||
result.current.setContextNotes([file("note.md")]);
|
||||
result.current.addImages([new File([], "img.png")]);
|
||||
result.current.setIncludeActiveWebTab(true);
|
||||
result.current.setQueue([queued("q1")]);
|
||||
});
|
||||
|
||||
act(() => result.current.migrateDraft("old", "new"));
|
||||
|
||||
// Once React observes the new session, its draft carries the typed content.
|
||||
rerender({ activeSessionId: "new", liveSessionIds: ["new"], defaultIncludeActiveNote: false });
|
||||
expect(result.current.input).toBe("typed during startup");
|
||||
expect(result.current.contextNotes.map((n) => n.path)).toEqual(["note.md"]);
|
||||
expect(result.current.images).toHaveLength(1);
|
||||
expect(result.current.includeActiveWebTab).toBe(true);
|
||||
expect(result.current.queue.map((q) => q.id)).toEqual(["q1"]);
|
||||
|
||||
// The old id no longer holds the migrated draft.
|
||||
rerender({ activeSessionId: "old", liveSessionIds: ["new"], defaultIncludeActiveNote: false });
|
||||
expect(result.current.input).toBe("");
|
||||
});
|
||||
|
||||
it("does not migrate an empty source draft (clean swap stays clean)", () => {
|
||||
const { result, rerender } = renderDrafts({
|
||||
activeSessionId: "old",
|
||||
liveSessionIds: ["old"],
|
||||
defaultIncludeActiveNote: false,
|
||||
});
|
||||
|
||||
act(() => result.current.migrateDraft("old", "new"));
|
||||
|
||||
rerender({ activeSessionId: "new", liveSessionIds: ["new"], defaultIncludeActiveNote: false });
|
||||
expect(result.current.input).toBe("");
|
||||
});
|
||||
|
||||
it("does not clobber input the user already started on the replacement", () => {
|
||||
const { result, rerender } = renderDrafts({
|
||||
activeSessionId: "old",
|
||||
liveSessionIds: ["old", "new"],
|
||||
defaultIncludeActiveNote: false,
|
||||
});
|
||||
|
||||
act(() => result.current.setInput("old draft"));
|
||||
rerender({
|
||||
activeSessionId: "new",
|
||||
liveSessionIds: ["old", "new"],
|
||||
defaultIncludeActiveNote: false,
|
||||
});
|
||||
act(() => result.current.setInput("new draft"));
|
||||
|
||||
act(() => result.current.migrateDraft("old", "new"));
|
||||
expect(result.current.input).toBe("new draft");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -60,6 +60,13 @@ export interface AgentInputDraftControls extends AgentInputDraft {
|
|||
setIncludeActiveWebTab: (include: boolean) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setQueue: React.Dispatch<React.SetStateAction<QueuedAgentMessage[]>>;
|
||||
/**
|
||||
* Carry a compose draft from a replaced session onto its replacement, so text
|
||||
* typed during an in-place session swap (e.g. the empty-landing context
|
||||
* refresh) survives the old session's pruning. No-op when the source draft is
|
||||
* empty or the target already holds user input. See {@link migrateDraft}.
|
||||
*/
|
||||
migrateDraft: (fromSessionId: string, toSessionId: string) => void;
|
||||
/** Clear the compose fields after a send; leaves loading/queue untouched. */
|
||||
resetCompose: () => void;
|
||||
}
|
||||
|
|
@ -83,6 +90,19 @@ const createDraft = (includeActiveNote: boolean): AgentInputDraft => ({
|
|||
const applyArrayState = <T>(value: React.SetStateAction<T[]>, previous: T[]): T[] =>
|
||||
typeof value === "function" ? value(previous) : value;
|
||||
|
||||
/**
|
||||
* Whether a draft carries anything worth preserving across a session swap —
|
||||
* any composed text/attachments/queue, or a toggle the user moved off its seed.
|
||||
* An untouched draft migrates to nothing, so the swap stays a clean reset.
|
||||
*/
|
||||
const hasDraftPayload = (draft: AgentInputDraft, defaultIncludeActiveNote: boolean): boolean =>
|
||||
draft.input.length > 0 ||
|
||||
draft.images.length > 0 ||
|
||||
draft.contextNotes.length > 0 ||
|
||||
draft.queue.length > 0 ||
|
||||
draft.includeActiveNote !== defaultIncludeActiveNote ||
|
||||
draft.includeActiveWebTab;
|
||||
|
||||
export function useAgentInputDrafts({
|
||||
activeSessionId,
|
||||
liveSessionIds,
|
||||
|
|
@ -177,6 +197,39 @@ export function useAgentInputDrafts({
|
|||
[updateActive]
|
||||
);
|
||||
|
||||
// Move a draft onto a replacement session id during an in-place swap. Writes
|
||||
// `setDrafts` DIRECTLY (not via `updateDraft`) on purpose: the new id isn't in
|
||||
// `liveSetRef` yet — React hasn't observed the manager's new session list when
|
||||
// the swap resolves — so `updateDraft`'s live-guard would drop the write. The
|
||||
// caller runs this synchronously the moment `replaceSessionInPlace` resolves,
|
||||
// while the old session's `closeSession` is still suspended at `await cancel()`
|
||||
// (it deletes the session only afterwards), so the source draft is still
|
||||
// present here and the prune effect only fires later. `loading` resets — the
|
||||
// replacement starts its own turn.
|
||||
const migrateDraft = useCallback(
|
||||
(fromSessionId: string, toSessionId: string) => {
|
||||
if (fromSessionId === toSessionId) return;
|
||||
setDrafts((prev) => {
|
||||
const source = prev[fromSessionId];
|
||||
if (!source || !hasDraftPayload(source, defaultIncludeActiveNote)) return prev;
|
||||
// Never clobber input the user already started on the replacement.
|
||||
const target = prev[toSessionId];
|
||||
if (target && hasDraftPayload(target, defaultIncludeActiveNote)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[fromSessionId];
|
||||
next[toSessionId] = {
|
||||
...source,
|
||||
images: [...source.images],
|
||||
contextNotes: [...source.contextNotes],
|
||||
queue: [...source.queue],
|
||||
loading: false,
|
||||
};
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[defaultIncludeActiveNote]
|
||||
);
|
||||
|
||||
// DESIGN NOTE: resetCompose hard-clears includeActiveNote to false after a
|
||||
// send, so within one session the active note auto-attaches only to the
|
||||
// FIRST message. Two scenarios, only one of which matches the legacy
|
||||
|
|
@ -241,6 +294,7 @@ export function useAgentInputDrafts({
|
|||
setIncludeActiveWebTab,
|
||||
setLoading,
|
||||
setQueue,
|
||||
migrateDraft,
|
||||
resetCompose,
|
||||
}),
|
||||
[
|
||||
|
|
@ -253,6 +307,7 @@ export function useAgentInputDrafts({
|
|||
setIncludeActiveWebTab,
|
||||
setLoading,
|
||||
setQueue,
|
||||
migrateDraft,
|
||||
resetCompose,
|
||||
]
|
||||
);
|
||||
|
|
|
|||
15
src/agentMode/ui/hooks/useAttentionChatIds.ts
Normal file
15
src/agentMode/ui/hooks/useAttentionChatIds.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { AgentSessionManager } from "@/agentMode/session/AgentSessionManager";
|
||||
import { useManagerSetSnapshot } from "@/agentMode/ui/hooks/useManagerSetSnapshot";
|
||||
|
||||
const getAttentionSnapshot = (manager: AgentSessionManager): ReadonlySet<string> =>
|
||||
manager.getAttentionChatIds();
|
||||
|
||||
/**
|
||||
* Reactive set of recent-list ids currently flagging needs-attention — the
|
||||
* live complement to the `item.needsAttention` snapshot, so a row's done-dot
|
||||
* lights up the moment its backgrounded turn finishes (taking over from the
|
||||
* running spinner) instead of waiting for the next history reload.
|
||||
*/
|
||||
export function useAttentionChatIds(manager: AgentSessionManager): ReadonlySet<string> {
|
||||
return useManagerSetSnapshot(manager, getAttentionSnapshot);
|
||||
}
|
||||
41
src/agentMode/ui/hooks/useManagerSetSnapshot.ts
Normal file
41
src/agentMode/ui/hooks/useManagerSetSnapshot.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { AgentSessionManager } from "@/agentMode/session/AgentSessionManager";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/** True when both sets hold exactly the same ids — used to skip no-op renders. */
|
||||
function sameMembership(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
|
||||
if (a === b) return true;
|
||||
if (a.size !== b.size) return false;
|
||||
for (const id of a) {
|
||||
if (!b.has(id)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive snapshot of a manager-derived id set (running chats, attention
|
||||
* chats, …), resynced on every manager notify. New snapshots are adopted only
|
||||
* when the membership actually changes, so an unrelated notify (tab switch,
|
||||
* label edit) doesn't churn the consumer's reference. `getSnapshot` is held in
|
||||
* a ref so an inline arrow at the call site doesn't tear down the subscription
|
||||
* each render.
|
||||
*/
|
||||
export function useManagerSetSnapshot(
|
||||
manager: AgentSessionManager,
|
||||
getSnapshot: (manager: AgentSessionManager) => ReadonlySet<string>
|
||||
): ReadonlySet<string> {
|
||||
const getSnapshotRef = useRef(getSnapshot);
|
||||
getSnapshotRef.current = getSnapshot;
|
||||
|
||||
const [ids, setIds] = useState<ReadonlySet<string>>(() => getSnapshot(manager));
|
||||
|
||||
useEffect(() => {
|
||||
const sync = (): void => {
|
||||
const next = getSnapshotRef.current(manager);
|
||||
setIds((prev) => (sameMembership(prev, next) ? prev : next));
|
||||
};
|
||||
sync();
|
||||
return manager.subscribe(sync);
|
||||
}, [manager]);
|
||||
|
||||
return ids;
|
||||
}
|
||||
151
src/agentMode/ui/hooks/usePersistentContextDrop.test.tsx
Normal file
151
src/agentMode/ui/hooks/usePersistentContextDrop.test.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { usePersistentContextDrop } from "@/agentMode/ui/hooks/usePersistentContextDrop";
|
||||
import type { ProjectConfig } from "@/aiParams";
|
||||
import { updateCachedProjectRecords } from "@/projects/state";
|
||||
import { createPatternSettingsValue, getDecodedPatterns } from "@/search/searchUtils";
|
||||
import { createEvent, fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import { App, Notice, TFile, TFolder } from "obsidian";
|
||||
import React, { useRef } from "react";
|
||||
|
||||
const mockUpdateProject = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
jest.mock("@/projects/ProjectFileManager", () => ({
|
||||
ProjectFileManager: {
|
||||
getInstance: () => ({ updateProject: mockUpdateProject }),
|
||||
},
|
||||
}));
|
||||
|
||||
function makeProject(overrides: Partial<ProjectConfig> = {}): ProjectConfig {
|
||||
return {
|
||||
id: "p1",
|
||||
name: "Halcyon Scope",
|
||||
systemPrompt: "",
|
||||
projectModelKey: "",
|
||||
modelConfigs: {},
|
||||
contextSource: {},
|
||||
created: 0,
|
||||
UsageTimestamps: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function seedProject(project: ProjectConfig) {
|
||||
updateCachedProjectRecords([
|
||||
{ project, filePath: "Halcyon Scope/project.md", folderName: "Halcyon Scope" },
|
||||
]);
|
||||
}
|
||||
|
||||
/** App whose vault resolves the given path-keyed abstract files. */
|
||||
function makeApp(byPath: Record<string, TFile | TFolder>): App {
|
||||
return {
|
||||
vault: {
|
||||
getAbstractFileByPath: (path: string) => byPath[path] ?? null,
|
||||
},
|
||||
} as unknown as App;
|
||||
}
|
||||
|
||||
function Harness({ app, projectId }: { app: App; projectId: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
usePersistentContextDrop({ app, projectId, dropRef: ref });
|
||||
return <div ref={ref} data-testid="zone" />;
|
||||
}
|
||||
|
||||
interface FakeItem {
|
||||
kind: "string" | "file";
|
||||
value?: string;
|
||||
}
|
||||
|
||||
function dispatchDrop(zone: HTMLElement, items: FakeItem[]) {
|
||||
const dataTransfer = {
|
||||
types: [] as string[],
|
||||
dropEffect: "",
|
||||
items: items.map((it) => ({
|
||||
kind: it.kind,
|
||||
getAsString: (cb: (data: string) => void) => cb(it.value ?? ""),
|
||||
})),
|
||||
};
|
||||
const event = createEvent.drop(zone);
|
||||
Object.defineProperty(event, "dataTransfer", { value: dataTransfer });
|
||||
fireEvent(zone, event);
|
||||
}
|
||||
|
||||
describe("usePersistentContextDrop", () => {
|
||||
beforeEach(() => {
|
||||
mockUpdateProject.mockClear();
|
||||
(Notice as unknown as jest.Mock).mockClear();
|
||||
});
|
||||
afterEach(() => updateCachedProjectRecords([]));
|
||||
|
||||
it("persists a dropped folder as a new inclusion via updateProject", async () => {
|
||||
seedProject(makeProject());
|
||||
const folder = new (TFolder as unknown as new (p: string) => TFolder)("notes/ideas");
|
||||
const app = makeApp({ "notes/ideas": folder });
|
||||
|
||||
const { getByTestId } = render(<Harness app={app} projectId="p1" />);
|
||||
dispatchDrop(getByTestId("zone"), [{ kind: "string", value: "notes/ideas" }]);
|
||||
|
||||
await waitFor(() => expect(mockUpdateProject).toHaveBeenCalledTimes(1));
|
||||
const [, nextConfig] = mockUpdateProject.mock.calls[0];
|
||||
expect(getDecodedPatterns(nextConfig.contextSource.inclusions)).toContain("notes/ideas");
|
||||
});
|
||||
|
||||
it("persists a dropped note as a [[basename]] inclusion", async () => {
|
||||
seedProject(makeProject());
|
||||
const file = new (TFile as unknown as new (p: string) => TFile)("notes/Intro.md");
|
||||
const app = makeApp({ "notes/Intro.md": file });
|
||||
|
||||
const { getByTestId } = render(<Harness app={app} projectId="p1" />);
|
||||
dispatchDrop(getByTestId("zone"), [{ kind: "string", value: "notes/Intro.md" }]);
|
||||
|
||||
await waitFor(() => expect(mockUpdateProject).toHaveBeenCalledTimes(1));
|
||||
const [, nextConfig] = mockUpdateProject.mock.calls[0];
|
||||
expect(getDecodedPatterns(nextConfig.contextSource.inclusions)).toContain("[[Intro]]");
|
||||
});
|
||||
|
||||
it("parses an obsidian:// URI with trailing params (file is not the last query key)", async () => {
|
||||
seedProject(makeProject());
|
||||
const file = new (TFile as unknown as new (p: string) => TFile)("notes/A.md");
|
||||
const app = makeApp({ "notes/A.md": file });
|
||||
|
||||
const { getByTestId } = render(<Harness app={app} projectId="p1" />);
|
||||
dispatchDrop(getByTestId("zone"), [
|
||||
{ kind: "string", value: "obsidian://open?vault=V&file=notes%2FA.md&line=3" },
|
||||
]);
|
||||
|
||||
await waitFor(() => expect(mockUpdateProject).toHaveBeenCalledTimes(1));
|
||||
const [, nextConfig] = mockUpdateProject.mock.calls[0];
|
||||
expect(getDecodedPatterns(nextConfig.contextSource.inclusions)).toContain("[[A]]");
|
||||
});
|
||||
|
||||
it("is idempotent — a duplicate inclusion is not re-written", async () => {
|
||||
seedProject(
|
||||
makeProject({
|
||||
contextSource: { inclusions: createPatternSettingsValue({ notePatterns: ["[[Intro]]"] }) },
|
||||
})
|
||||
);
|
||||
const file = new (TFile as unknown as new (p: string) => TFile)("notes/Intro.md");
|
||||
const app = makeApp({ "notes/Intro.md": file });
|
||||
|
||||
const { getByTestId } = render(<Harness app={app} projectId="p1" />);
|
||||
dispatchDrop(getByTestId("zone"), [{ kind: "string", value: "notes/Intro.md" }]);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(Notice as unknown as jest.Mock).toHaveBeenCalledWith("Already in project context")
|
||||
);
|
||||
expect(mockUpdateProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects external OS files (no vault item resolved)", async () => {
|
||||
seedProject(makeProject());
|
||||
const app = makeApp({});
|
||||
|
||||
const { getByTestId } = render(<Harness app={app} projectId="p1" />);
|
||||
dispatchDrop(getByTestId("zone"), [{ kind: "file" }]);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(Notice as unknown as jest.Mock).toHaveBeenCalledWith(
|
||||
"Only vault files or folders can be added to project context"
|
||||
)
|
||||
);
|
||||
expect(mockUpdateProject).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
244
src/agentMode/ui/hooks/usePersistentContextDrop.ts
Normal file
244
src/agentMode/ui/hooks/usePersistentContextDrop.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import type { ProjectConfig } from "@/aiParams";
|
||||
import { logWarn } from "@/logger";
|
||||
import { ProjectFileManager } from "@/projects/ProjectFileManager";
|
||||
import { getCachedProjectRecordById } from "@/projects/state";
|
||||
import {
|
||||
createPatternSettingsValue,
|
||||
getFilePattern,
|
||||
getMatchingPatterns,
|
||||
} from "@/search/searchUtils";
|
||||
import { App, Notice, TFile, TFolder, type TAbstractFile } from "obsidian";
|
||||
import { RefObject, useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Props for {@link usePersistentContextDrop}.
|
||||
*
|
||||
* Distinct from `useChatFileDrop` on purpose: that hook adds notes to the
|
||||
* CURRENT message draft (transient), this one writes a PERSISTENT project
|
||||
* inclusion that applies to future chats. The two live in different drop zones
|
||||
* inside the same chat container, split by a two-sided contract: the container
|
||||
* hook yields while a drag hovers an element marked `data-copilot-drop-zone`
|
||||
* (its capture-phase check), and this hook `stopPropagation()`s only on DROP so
|
||||
* the dropped item doesn't also land in the draft. dragover is left bubbling —
|
||||
* Obsidian's drag manager repositions the drag ghost at the document level.
|
||||
*/
|
||||
export interface UsePersistentContextDropProps {
|
||||
/** Obsidian app instance for vault lookups + project writes. */
|
||||
app: App;
|
||||
/** Project whose `contextSource.inclusions` receives the dropped item. */
|
||||
projectId: string;
|
||||
/** The drop-zone element; native drag listeners attach here. */
|
||||
dropRef: RefObject<HTMLElement>;
|
||||
/** When false, listeners are not attached (e.g. section collapsed). */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UsePersistentContextDropReturn {
|
||||
/** True while a droppable drag hovers the zone — drives the hover styling. */
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
/** Drag originating inside the plugin (e.g. relevant-note chips) — never a drop target. */
|
||||
const INTERNAL_DRAG_TYPE = "copilot/internal-drag";
|
||||
|
||||
/**
|
||||
* Extract the `file` param from an `obsidian://open?...` URI, or null when the
|
||||
* input isn't such a URI. Parses by query key (not a greedy tail match) so extra
|
||||
* params like `&line=12` don't get folded into the path.
|
||||
*/
|
||||
function obsidianOpenFileParam(raw: string): string | null {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (url.protocol !== "obsidian:" || url.hostname !== "open") return null;
|
||||
return url.searchParams.get("file");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one dropped line to a vault file or folder. Accepts both the
|
||||
* `obsidian://open?...&file=<path>` URI form (nav-bar / explorer drags) and a
|
||||
* bare vault-relative path, and retries with a `.md` suffix for extensionless
|
||||
* note links. Returns a {@link TFolder} as well as a {@link TFile} — folders are
|
||||
* valid project inclusions (the materializer pulls the whole directory).
|
||||
*/
|
||||
function resolveDroppedAbstractFile(app: App, raw: string): TAbstractFile | null {
|
||||
const line = raw.trim();
|
||||
if (!line) return null;
|
||||
|
||||
const path = obsidianOpenFileParam(line) ?? line;
|
||||
return (
|
||||
app.vault.getAbstractFileByPath(path) ?? app.vault.getAbstractFileByPath(`${path}.md`) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the dropped vault items as project inclusions. Idempotent: a
|
||||
* folder becomes a bare folder pattern, a file becomes a `[[basename]]` note
|
||||
* pattern; anything already present is skipped. Re-encodes via the canonical
|
||||
* `createPatternSettingsValue` helper (never a raw append) and writes through
|
||||
* `updateProject`. Returns the number of new patterns added.
|
||||
*/
|
||||
async function persistInclusions(
|
||||
app: App,
|
||||
projectId: string,
|
||||
files: TAbstractFile[]
|
||||
): Promise<number> {
|
||||
const record = getCachedProjectRecordById(projectId);
|
||||
if (!record) {
|
||||
new Notice("Project not found — could not add to context");
|
||||
return 0;
|
||||
}
|
||||
|
||||
const project = record.project;
|
||||
const { inclusions: existing } = getMatchingPatterns({
|
||||
inclusions: project.contextSource?.inclusions,
|
||||
isProject: true,
|
||||
});
|
||||
const folderPatterns = new Set(existing?.folderPatterns ?? []);
|
||||
const notePatterns = new Set(existing?.notePatterns ?? []);
|
||||
const tagPatterns = new Set(existing?.tagPatterns ?? []);
|
||||
const extensionPatterns = new Set(existing?.extensionPatterns ?? []);
|
||||
|
||||
let added = 0;
|
||||
for (const file of files) {
|
||||
if (file instanceof TFolder) {
|
||||
// Skip the vault root (empty path): it would include everything.
|
||||
if (file.path && !folderPatterns.has(file.path)) {
|
||||
folderPatterns.add(file.path);
|
||||
added++;
|
||||
}
|
||||
} else if (file instanceof TFile) {
|
||||
const pattern = getFilePattern(file);
|
||||
if (!notePatterns.has(pattern)) {
|
||||
notePatterns.add(pattern);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (added === 0) return 0;
|
||||
|
||||
const inclusions = createPatternSettingsValue({
|
||||
tagPatterns: [...tagPatterns],
|
||||
extensionPatterns: [...extensionPatterns],
|
||||
folderPatterns: [...folderPatterns],
|
||||
notePatterns: [...notePatterns],
|
||||
});
|
||||
const next: ProjectConfig = {
|
||||
...project,
|
||||
contextSource: { ...project.contextSource, inclusions },
|
||||
};
|
||||
await ProjectFileManager.getInstance(app).updateProject(projectId, next);
|
||||
return added;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every string item off a drop event (each `getAsString` is async, so
|
||||
* collect the promises first to avoid races), then resolve them to vault
|
||||
* files/folders. Multi-file nav-bar drags arrive as a single newline-joined
|
||||
* string, so split each item by line.
|
||||
*/
|
||||
async function collectDroppedFiles(app: App, items: DataTransferItem[]): Promise<TAbstractFile[]> {
|
||||
const strings = await Promise.all(
|
||||
items.map(
|
||||
(item) =>
|
||||
new Promise<string>((resolve) => {
|
||||
item.getAsString((data) => resolve(data));
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const byPath = new Map<string, TAbstractFile>();
|
||||
for (const raw of strings) {
|
||||
for (const line of raw.split("\n")) {
|
||||
const file = resolveDroppedAbstractFile(app, line);
|
||||
if (file) byPath.set(file.path, file);
|
||||
}
|
||||
}
|
||||
return [...byPath.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Native drag-and-drop for the project Context section's drop zone. Writes a
|
||||
* PERSISTENT inclusion (applies to new chats; no mid-session re-materialize) and
|
||||
* rejects external OS files, which can't become vault inclusions.
|
||||
*/
|
||||
export function usePersistentContextDrop(
|
||||
props: UsePersistentContextDropProps
|
||||
): UsePersistentContextDropReturn {
|
||||
const { app, projectId, dropRef, enabled = true } = props;
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const zone = dropRef.current;
|
||||
if (!zone || !enabled) return;
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
if (!e.dataTransfer) return;
|
||||
// NO stopPropagation here: the dragover must keep bubbling to the
|
||||
// document, where Obsidian's drag manager repositions the drag ghost —
|
||||
// swallowing it freezes the ghost at the zone's edge. The container's
|
||||
// chat-file handler yields on its own (capture-phase
|
||||
// `data-copilot-drop-zone` check in useChatFileDrop), so there's nothing
|
||||
// to suppress.
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.types.includes(INTERNAL_DRAG_TYPE)) return;
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
const rect = zone.getBoundingClientRect();
|
||||
const { clientX: x, clientY: y } = e;
|
||||
if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
|
||||
setIsDragging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (e: DragEvent) => {
|
||||
if (!e.dataTransfer) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
if (e.dataTransfer.types.includes(INTERNAL_DRAG_TYPE)) return;
|
||||
|
||||
const all = Array.from(e.dataTransfer.items);
|
||||
const stringItems = all.filter((item) => item.kind === "string");
|
||||
const hasExternalFiles = all.some((item) => item.kind === "file");
|
||||
|
||||
const files = stringItems.length > 0 ? await collectDroppedFiles(app, stringItems) : [];
|
||||
if (files.length === 0) {
|
||||
if (hasExternalFiles) {
|
||||
new Notice("Only vault files or folders can be added to project context");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const added = await persistInclusions(app, projectId, files);
|
||||
if (added > 0) {
|
||||
new Notice("Added to project context");
|
||||
} else {
|
||||
new Notice("Already in project context");
|
||||
}
|
||||
} catch (err) {
|
||||
logWarn("[project-context] failed to add dropped inclusion", err);
|
||||
new Notice("Could not add to project context");
|
||||
}
|
||||
};
|
||||
|
||||
const onDrop = (e: DragEvent) => void handleDrop(e);
|
||||
zone.addEventListener("dragover", handleDragOver);
|
||||
zone.addEventListener("dragleave", handleDragLeave);
|
||||
zone.addEventListener("drop", onDrop);
|
||||
return () => {
|
||||
zone.removeEventListener("dragover", handleDragOver);
|
||||
zone.removeEventListener("dragleave", handleDragLeave);
|
||||
zone.removeEventListener("drop", onDrop);
|
||||
};
|
||||
}, [app, projectId, dropRef, enabled]);
|
||||
|
||||
return { isDragging };
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
import { useRefreshEmptyLandingOnContextSourceChange } from "@/agentMode/ui/hooks/useRefreshEmptyLandingOnContextSourceChange";
|
||||
import { GLOBAL_SCOPE } from "@/agentMode/session/scope";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
interface Props {
|
||||
activeProjectId: string;
|
||||
signature: string | null;
|
||||
isLanding: boolean;
|
||||
blocking: boolean;
|
||||
draftEmpty: boolean;
|
||||
refresh: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Drives the hook through a real component so effects + ref updates run as in
|
||||
* production. Each render passes the current props verbatim. */
|
||||
function Harness(props: Props) {
|
||||
useRefreshEmptyLandingOnContextSourceChange(props);
|
||||
return null;
|
||||
}
|
||||
|
||||
const BASE: Props = {
|
||||
activeProjectId: "p1",
|
||||
signature: "sig-a",
|
||||
isLanding: true,
|
||||
blocking: false,
|
||||
draftEmpty: true,
|
||||
refresh: async () => true,
|
||||
};
|
||||
|
||||
/** Flush the microtasks the hook's then/finally chain schedules. */
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("useRefreshEmptyLandingOnContextSourceChange", () => {
|
||||
it("seeds on first sight without refreshing", async () => {
|
||||
const refresh = jest.fn(async () => true);
|
||||
render(<Harness {...BASE} refresh={refresh} />);
|
||||
await flush();
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes when the signature changes on an empty landing", async () => {
|
||||
const refresh = jest.fn(async () => true);
|
||||
const { rerender } = render(<Harness {...BASE} refresh={refresh} />);
|
||||
await flush();
|
||||
rerender(<Harness {...BASE} signature="sig-b" refresh={refresh} />);
|
||||
await flush();
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not refresh on an unchanged signature (re-render churn)", async () => {
|
||||
const refresh = jest.fn(async () => true);
|
||||
const { rerender } = render(<Harness {...BASE} refresh={refresh} />);
|
||||
await flush();
|
||||
rerender(<Harness {...BASE} refresh={refresh} />);
|
||||
rerender(<Harness {...BASE} refresh={refresh} />);
|
||||
await flush();
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defers while the draft is dirty, then refreshes once it empties", async () => {
|
||||
const refresh = jest.fn(async () => true);
|
||||
const { rerender } = render(<Harness {...BASE} refresh={refresh} />);
|
||||
await flush();
|
||||
// Source changes while the user is typing — no refresh yet.
|
||||
rerender(<Harness {...BASE} signature="sig-b" draftEmpty={false} refresh={refresh} />);
|
||||
await flush();
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
// User clears the input — the deferred change is now picked up.
|
||||
rerender(<Harness {...BASE} signature="sig-b" draftEmpty={true} refresh={refresh} />);
|
||||
await flush();
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("defers while blocking, then refreshes once it clears", async () => {
|
||||
const refresh = jest.fn(async () => true);
|
||||
const { rerender } = render(<Harness {...BASE} refresh={refresh} />);
|
||||
await flush();
|
||||
rerender(<Harness {...BASE} signature="sig-b" blocking={true} refresh={refresh} />);
|
||||
await flush();
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
rerender(<Harness {...BASE} signature="sig-b" blocking={false} refresh={refresh} />);
|
||||
await flush();
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("accepts the new signature on a conversation without refreshing", async () => {
|
||||
const refresh = jest.fn(async () => true);
|
||||
const { rerender } = render(<Harness {...BASE} refresh={refresh} />);
|
||||
await flush();
|
||||
// Not a landing: accept silently (next New Chat reads fresh config)…
|
||||
rerender(<Harness {...BASE} signature="sig-b" isLanding={false} refresh={refresh} />);
|
||||
await flush();
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
// …and because the baseline advanced, returning to a landing at the SAME
|
||||
// signature must not retroactively refresh.
|
||||
rerender(<Harness {...BASE} signature="sig-b" isLanding={true} refresh={refresh} />);
|
||||
await flush();
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-seeds on a project switch instead of diffing across projects", async () => {
|
||||
const refresh = jest.fn(async () => true);
|
||||
const { rerender } = render(<Harness {...BASE} refresh={refresh} />);
|
||||
await flush();
|
||||
// A different project with a different signature is a switch, not an edit.
|
||||
rerender(<Harness {...BASE} activeProjectId="p2" signature="sig-z" refresh={refresh} />);
|
||||
await flush();
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a no-op for the global scope", async () => {
|
||||
const refresh = jest.fn(async () => true);
|
||||
const { rerender } = render(
|
||||
<Harness {...BASE} activeProjectId={GLOBAL_SCOPE} signature={null} refresh={refresh} />
|
||||
);
|
||||
await flush();
|
||||
rerender(
|
||||
<Harness {...BASE} activeProjectId={GLOBAL_SCOPE} signature={null} refresh={refresh} />
|
||||
);
|
||||
await flush();
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not tight-loop when a refresh keeps failing", async () => {
|
||||
// A guarded no-op / failure resolves false; the baseline must stay put
|
||||
// WITHOUT self-ticking, so it retries only on a real dependency change.
|
||||
const refresh = jest.fn(async () => false);
|
||||
const { rerender } = render(<Harness {...BASE} refresh={refresh} />);
|
||||
await flush();
|
||||
rerender(<Harness {...BASE} signature="sig-b" refresh={refresh} />);
|
||||
await flush();
|
||||
await flush();
|
||||
// Exactly one attempt for the one signature change — no self-driven retries.
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("converges to the final signature when it changes again mid-flight", async () => {
|
||||
// Hold the first refresh open so the single-flight guard is active while a
|
||||
// newer edit lands; on settle the hook must catch up to the LATEST signature.
|
||||
let releaseFirst!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
let call = 0;
|
||||
const refresh = jest.fn(async () => {
|
||||
call += 1;
|
||||
if (call === 1) await gate; // first replace stays in flight
|
||||
return true;
|
||||
});
|
||||
|
||||
const { rerender } = render(<Harness {...BASE} refresh={refresh} />);
|
||||
await flush();
|
||||
// sig-a → sig-b kicks off the first (held) refresh.
|
||||
rerender(<Harness {...BASE} signature="sig-b" refresh={refresh} />);
|
||||
await flush();
|
||||
// While it's in flight, sig-b → sig-c arrives — gated, no second call yet.
|
||||
rerender(<Harness {...BASE} signature="sig-c" refresh={refresh} />);
|
||||
await flush();
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
// Release the first replace; the success tick re-evaluates and, since the
|
||||
// live signature is now sig-c (≠ the captured sig-b baseline), refreshes again.
|
||||
await act(async () => {
|
||||
releaseFirst();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(refresh).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import { GLOBAL_SCOPE } from "@/agentMode/session/scope";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface UseRefreshEmptyLandingOnContextSourceChangeParams {
|
||||
/** Active workspace scope; {@link GLOBAL_SCOPE} has no project context to track. */
|
||||
activeProjectId: string;
|
||||
/**
|
||||
* The active project's landing-capture signature, or null for global/orphaned
|
||||
* scope. Fingerprints the inputs an empty landing session bakes in at creation
|
||||
* — the materialized context sources PLUS the project instruction body — so a
|
||||
* System-Prompt-only edit triggers a refresh too. Broader than the session
|
||||
* manager's materialization dirty signature on purpose. Computed by the caller
|
||||
* from the live project record so it re-derives on every store change that
|
||||
* re-renders the host.
|
||||
*/
|
||||
signature: string | null;
|
||||
/** Whether the active session is still an empty landing (no user messages). */
|
||||
isLanding: boolean;
|
||||
/** Context still materializing — replacing the session now would join a run
|
||||
* that's about to be discarded, so we defer until it clears. */
|
||||
blocking: boolean;
|
||||
/** A replace prunes the draft, so we only refresh when there's nothing to lose. */
|
||||
draftEmpty: boolean;
|
||||
/** Replaces the empty landing session in place; resolves to whether a swap
|
||||
* actually happened (false = guarded no-op, e.g. the draft filled in by the
|
||||
* time it ran), so the baseline only advances on a real capture. */
|
||||
refresh: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the empty landing session when the active project's captured inputs
|
||||
* change underneath it.
|
||||
*
|
||||
* Every project mutation — drag-drop, inline edit, +URL, chip removal, the
|
||||
* Manage modal, a System-Prompt edit — funnels through `updateProject` → the
|
||||
* project store, which re-renders the host. Observing the active project's
|
||||
* landing-capture signature here therefore covers every entry point (current and
|
||||
* future) without each mutation site wiring its own callback.
|
||||
*
|
||||
* The signature is the only trigger. A single-flight ref serializes the
|
||||
* (non-idempotent) session replacement. The baseline ref records the last
|
||||
* signature we've RECONCILED and only advances when a change is truly settled —
|
||||
* a successful refresh, or a deliberate accept (`!isLanding`). A change that's
|
||||
* merely deferred (blocking, in-flight, or a dirty draft) leaves the baseline
|
||||
* behind, so it's reconsidered the moment its gate clears: the draft emptying,
|
||||
* blocking ending, or the in-flight replace settling each re-runs the
|
||||
* evaluation. A FAILED refresh likewise leaves the baseline behind but does not
|
||||
* self-retry — it waits for the next real dependency change rather than looping.
|
||||
*/
|
||||
export function useRefreshEmptyLandingOnContextSourceChange({
|
||||
activeProjectId,
|
||||
signature,
|
||||
isLanding,
|
||||
blocking,
|
||||
draftEmpty,
|
||||
refresh,
|
||||
}: UseRefreshEmptyLandingOnContextSourceChangeParams): void {
|
||||
// The refresh closure depends on the draft, so it changes every keystroke;
|
||||
// hold it in a ref so it never widens the evaluation effect's dependencies.
|
||||
const refreshRef = useRef(refresh);
|
||||
useEffect(() => {
|
||||
refreshRef.current = refresh;
|
||||
}, [refresh]);
|
||||
|
||||
const baselineRef = useRef<{ projectId: string; signature: string } | null>(null);
|
||||
const inFlightRef = useRef(false);
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(
|
||||
() => () => {
|
||||
mountedRef.current = false;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Bumped only after a SUCCESSFUL replace, to force one more evaluation that
|
||||
// converges a source change which arrived mid-flight (its signature already
|
||||
// changed during that render, so no dependency is left to re-trigger the
|
||||
// effect). A guarded no-op or a failure deliberately does NOT tick — those
|
||||
// wait for a real dependency change (draft emptying, signature/blocking
|
||||
// change) instead, so a persistent failure can't tight-loop.
|
||||
const [retryTick, setRetryTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Out of project scope: drop the baseline so re-entering a project re-seeds
|
||||
// rather than diffing against a stale signature.
|
||||
if (signature === null || activeProjectId === GLOBAL_SCOPE) {
|
||||
baselineRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const baseline = baselineRef.current;
|
||||
// First sight of this project (open or scope switch): seed, never refresh —
|
||||
// entering a project is not a source change.
|
||||
if (!baseline || baseline.projectId !== activeProjectId) {
|
||||
baselineRef.current = { projectId: activeProjectId, signature };
|
||||
return;
|
||||
}
|
||||
if (baseline.signature === signature) return;
|
||||
|
||||
// Captured input changed on a surface that won't take a refresh: a
|
||||
// conversation, not a landing. Accept the new signature — the next New Chat
|
||||
// reads fresh config.
|
||||
if (!isLanding) {
|
||||
baselineRef.current = { projectId: activeProjectId, signature };
|
||||
return;
|
||||
}
|
||||
|
||||
// Deferred — DON'T advance the baseline, so the change is reconsidered once
|
||||
// its gate clears: a dirty draft (emptying flips `draftEmpty`), an active
|
||||
// materialization (`blocking` flips via its atom), or an in-flight replace
|
||||
// (the retry tick below). Each is a dependency of this effect.
|
||||
if (!draftEmpty || blocking || inFlightRef.current) return;
|
||||
|
||||
inFlightRef.current = true;
|
||||
void refreshRef
|
||||
.current()
|
||||
.then((replaced) => {
|
||||
// Advance the baseline only on a real capture; a guarded no-op or a
|
||||
// failure leaves it behind so a later evaluation retries. Tick only on
|
||||
// success — see {@link retryTick}.
|
||||
if (replaced && mountedRef.current) {
|
||||
baselineRef.current = { projectId: activeProjectId, signature };
|
||||
setRetryTick((t) => t + 1);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// `refresh` swallows its own errors and resolves false; this is purely
|
||||
// defensive so a rejection can't leave the single-flight latched.
|
||||
})
|
||||
.finally(() => {
|
||||
inFlightRef.current = false;
|
||||
});
|
||||
}, [signature, activeProjectId, isLanding, blocking, draftEmpty, retryTick]);
|
||||
}
|
||||
74
src/agentMode/ui/hooks/useRunningChatIds.test.tsx
Normal file
74
src/agentMode/ui/hooks/useRunningChatIds.test.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { AgentSessionManager } from "@/agentMode/session/AgentSessionManager";
|
||||
import { useAttentionChatIds } from "@/agentMode/ui/hooks/useAttentionChatIds";
|
||||
import { useRunningChatIds } from "@/agentMode/ui/hooks/useRunningChatIds";
|
||||
|
||||
/**
|
||||
* Minimal manager stand-in exposing only the surfaces the hooks read:
|
||||
* `subscribe` (returns an unsubscribe) plus the two live id-set getters (both
|
||||
* serve the same backing set — each test exercises one hook at a time). A
|
||||
* handle lets the test swap the current set and fire the subscriber.
|
||||
*/
|
||||
function makeFakeManager(initial: ReadonlySet<string>) {
|
||||
let current = initial;
|
||||
const listeners = new Set<() => void>();
|
||||
const manager = {
|
||||
getRunningChatIds: () => current,
|
||||
getAttentionChatIds: () => current,
|
||||
subscribe: (l: () => void) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
},
|
||||
} as unknown as AgentSessionManager;
|
||||
return {
|
||||
manager,
|
||||
emit: (next: ReadonlySet<string>) => {
|
||||
current = next;
|
||||
for (const l of listeners) l();
|
||||
},
|
||||
unsubscribed: () => listeners.size === 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useRunningChatIds", () => {
|
||||
it("returns the initial running set on mount", () => {
|
||||
const { manager } = makeFakeManager(new Set(["a"]));
|
||||
const { result } = renderHook(() => useRunningChatIds(manager));
|
||||
expect(result.current.has("a")).toBe(true);
|
||||
});
|
||||
|
||||
it("updates when the set's contents change", () => {
|
||||
const fake = makeFakeManager(new Set(["a"]));
|
||||
const { result } = renderHook(() => useRunningChatIds(fake.manager));
|
||||
act(() => fake.emit(new Set(["a", "b"])));
|
||||
expect(result.current.has("b")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the same reference when contents are unchanged", () => {
|
||||
const fake = makeFakeManager(new Set(["a"]));
|
||||
const { result } = renderHook(() => useRunningChatIds(fake.manager));
|
||||
const before = result.current;
|
||||
// Same membership in a freshly-allocated Set must not churn the snapshot.
|
||||
act(() => fake.emit(new Set(["a"])));
|
||||
expect(result.current).toBe(before);
|
||||
});
|
||||
|
||||
it("unsubscribes on unmount", () => {
|
||||
const fake = makeFakeManager(new Set());
|
||||
const { unmount } = renderHook(() => useRunningChatIds(fake.manager));
|
||||
unmount();
|
||||
expect(fake.unsubscribed()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAttentionChatIds", () => {
|
||||
// The shared snapshot machinery is exercised above; this only pins that the
|
||||
// attention wrapper reads the attention getter and stays subscribed.
|
||||
it("tracks the manager's attention set", () => {
|
||||
const fake = makeFakeManager(new Set(["done-1"]));
|
||||
const { result } = renderHook(() => useAttentionChatIds(fake.manager));
|
||||
expect(result.current.has("done-1")).toBe(true);
|
||||
act(() => fake.emit(new Set(["done-1", "done-2"])));
|
||||
expect(result.current.has("done-2")).toBe(true);
|
||||
});
|
||||
});
|
||||
14
src/agentMode/ui/hooks/useRunningChatIds.ts
Normal file
14
src/agentMode/ui/hooks/useRunningChatIds.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { AgentSessionManager } from "@/agentMode/session/AgentSessionManager";
|
||||
import { useManagerSetSnapshot } from "@/agentMode/ui/hooks/useManagerSetSnapshot";
|
||||
|
||||
const getRunningSnapshot = (manager: AgentSessionManager): ReadonlySet<string> =>
|
||||
manager.getRunningChatIds();
|
||||
|
||||
/**
|
||||
* Reactive set of recent-list ids whose backend turn is currently running.
|
||||
* The manager re-notifies whenever a session's running membership flips, so
|
||||
* the landing rows can show/hide their spinner without polling.
|
||||
*/
|
||||
export function useRunningChatIds(manager: AgentSessionManager): ReadonlySet<string> {
|
||||
return useManagerSetSnapshot(manager, getRunningSnapshot);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { BaseChatModel } from "@langchain/core/language_models/chat_models";
|
|||
import { ChatPromptTemplate } from "@langchain/core/prompts";
|
||||
|
||||
import { ModelCapability, ReasoningEffort, Verbosity } from "@/constants";
|
||||
import type { MaterializedSourceType } from "@/context/contextCacheStore";
|
||||
import { settingsAtom, settingsStore } from "@/settings/model";
|
||||
import { SelectedTextContext } from "@/types/message";
|
||||
import { atom, useAtom } from "jotai";
|
||||
|
|
@ -41,6 +42,13 @@ export interface FailedItem {
|
|||
type: "md" | "web" | "youtube" | "nonMd";
|
||||
error?: string;
|
||||
timestamp?: number;
|
||||
/**
|
||||
* Agent project context only: the source's refresh failed but a previous
|
||||
* snapshot is still in use, so it's stale-but-usable rather than missing.
|
||||
* Lets the status icon stay "ready" (green) while the popover flags the
|
||||
* staleness. Undefined for the legacy CAG failure tracker.
|
||||
*/
|
||||
usedStaleSnapshot?: boolean;
|
||||
}
|
||||
|
||||
interface ProjectContextLoadState {
|
||||
|
|
@ -57,6 +65,72 @@ export const projectContextLoadAtom = atom<ProjectContextLoadState>({
|
|||
total: [],
|
||||
});
|
||||
|
||||
/** Done-of-total progress for one materialization step (prefetch / parse). */
|
||||
export interface ContextLoadStepCount {
|
||||
done: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface AgentProjectContextLoadState {
|
||||
phase: "idle" | "resolve" | "prefetch" | "parse" | "done";
|
||||
blocking: boolean; // true while send should be gated for this project
|
||||
/**
|
||||
* In-vault binary files queued for text materialization, known once the
|
||||
* materializer resolves inclusions. Drives the card's "Resolve files (N)" row.
|
||||
* Omitted until resolve completes (and stays omitted for a context with none).
|
||||
*/
|
||||
resolved?: number;
|
||||
/** Remote (web/YouTube) prefetch progress; omitted when there are no remotes. */
|
||||
prefetch?: ContextLoadStepCount;
|
||||
/** Binary-file parse progress; omitted when there are no files to parse. */
|
||||
parsed?: ContextLoadStepCount;
|
||||
/**
|
||||
* Per-source fetch/parse failures from the last run. A run with failures still
|
||||
* completes as `phase: "done"` (the session degrades gracefully); these drive
|
||||
* the status icon's warning state and the popover's failed-source list. Always
|
||||
* republished on `done` (empty array when everything succeeded) so a prior
|
||||
* run's failures never linger.
|
||||
*/
|
||||
failedSources?: FailedItem[];
|
||||
/**
|
||||
* Sources the full materialization run is fetching/parsing RIGHT NOW, mirroring
|
||||
* the legacy CAG `processingFiles` set. Published incrementally as each source
|
||||
* starts and settles, so the popover renders a true queue: URLs (fetched in
|
||||
* parallel) appear together while files (parsed sequentially) appear one at a
|
||||
* time, and each flips to its real outcome the instant it settles — never
|
||||
* waiting for the whole run. Only the single-flight owner publishes it; cleared
|
||||
* on `done`. `failedSources` is likewise published incrementally during a run.
|
||||
*/
|
||||
processingSources?: AgentInFlightSource[];
|
||||
/**
|
||||
* Sources whose per-source retry is currently in flight (the popover row
|
||||
* "Retry"). Drives an optimistic "processing" state on that row so a click has
|
||||
* immediate feedback even when the retry ends up failing again. Never gates
|
||||
* send (`blocking` stays false); cleared when each retry settles.
|
||||
*/
|
||||
retryingSources?: AgentRetryingSource[];
|
||||
}
|
||||
|
||||
/** A source whose per-source retry is currently in flight (popover row "Retry"). */
|
||||
export interface AgentRetryingSource {
|
||||
kind: MaterializedSourceType;
|
||||
source: string;
|
||||
}
|
||||
|
||||
/** A source the full materialization run is currently fetching/parsing. */
|
||||
export interface AgentInFlightSource {
|
||||
kind: MaterializedSourceType;
|
||||
source: string;
|
||||
}
|
||||
|
||||
/** Frozen empty list — referential stability for the "no retries in flight" case. */
|
||||
export const EMPTY_RETRYING_SOURCES: readonly AgentRetryingSource[] = Object.freeze([]);
|
||||
/** Frozen empty list — referential stability for the "nothing materializing" case. */
|
||||
export const EMPTY_PROCESSING_SOURCES: readonly AgentInFlightSource[] = Object.freeze([]);
|
||||
/** Per-project context-load state, keyed by projectId. Driven by AgentSessionManager's
|
||||
* materialize step; read by AgentContextStatusIcon / AgentChatInput to show progress + gate send. */
|
||||
export const agentProjectContextLoadAtom = atom<Record<string, AgentProjectContextLoadState>>({});
|
||||
|
||||
interface IndexingProgressState {
|
||||
isActive: boolean;
|
||||
isPaused: boolean;
|
||||
|
|
@ -84,6 +158,8 @@ export interface ProjectConfig {
|
|||
name: string;
|
||||
description?: string;
|
||||
systemPrompt: string;
|
||||
// Old CAG Project Chain model selector. Agent Mode does NOT read this (it uses
|
||||
// agentMode.activeBackend + the backend's default model); kept for CAG runtime + YAML compat.
|
||||
projectModelKey: string;
|
||||
modelConfigs: {
|
||||
temperature?: number;
|
||||
|
|
|
|||
|
|
@ -505,6 +505,17 @@ export function registerCommands(plugin: CopilotPlugin) {
|
|||
const fileCache = FileCache.getInstance<string>();
|
||||
await fileCache.clear(plugin.app.vault);
|
||||
|
||||
// Clear the off-vault shared conversion cache (Agent Mode snapshots +
|
||||
// markers). Desktop-gated + dynamic import so node:fs / conversionsLocation
|
||||
// never load on mobile (this command module is registered on all platforms).
|
||||
// clear() is root-confined to `context-cache/` — it never ascends to the
|
||||
// parent `vaults/<id>/`, so `agent-chat-index.json` is untouched.
|
||||
if (isDesktopRuntime()) {
|
||||
const { cacheRoot } = await import("@/context/conversionsLocation");
|
||||
const { createNodeContextCacheFs } = await import("@/context/contextCacheFs");
|
||||
await createNodeContextCacheFs(cacheRoot(plugin.app)).clear();
|
||||
}
|
||||
|
||||
new Notice("All Copilot caches cleared successfully");
|
||||
} catch (error) {
|
||||
logError("Error clearing Copilot caches:", error);
|
||||
|
|
|
|||
|
|
@ -44,6 +44,16 @@ interface ChatContextMenuProps {
|
|||
) => void;
|
||||
lexicalEditorRef?: React.RefObject<{ focus: () => void }>;
|
||||
hideAddContextButton?: boolean;
|
||||
/**
|
||||
* Agent Mode passes its project-context status icon here; it renders at the
|
||||
* right of the badge row. Legacy Chat leaves it undefined.
|
||||
*/
|
||||
statusIndicator?: React.ReactNode;
|
||||
/**
|
||||
* True in Agent Mode. Suppresses the legacy CAG project-status icon (Agent Mode
|
||||
* owns its own via {@link statusIndicator}); keeps the two from ever co-existing.
|
||||
*/
|
||||
isAgentMode?: boolean;
|
||||
}
|
||||
|
||||
export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
|
||||
|
|
@ -62,6 +72,8 @@ export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
|
|||
onTypeaheadSelect,
|
||||
lexicalEditorRef,
|
||||
hideAddContextButton = false,
|
||||
statusIndicator,
|
||||
isAgentMode = false,
|
||||
}) => {
|
||||
const app = useApp();
|
||||
const [currentChain] = useChainType();
|
||||
|
|
@ -216,7 +228,7 @@ export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
|
|||
))}
|
||||
</div>
|
||||
|
||||
{currentChain === ChainType.PROJECT_CHAIN && (
|
||||
{!isAgentMode && currentChain === ChainType.PROJECT_CHAIN && (
|
||||
<>
|
||||
<Separator orientation="vertical" />
|
||||
<div className="">
|
||||
|
|
@ -232,6 +244,13 @@ export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
|
|||
</>
|
||||
)}
|
||||
|
||||
{statusIndicator && (
|
||||
<>
|
||||
<Separator orientation="vertical" />
|
||||
{statusIndicator}
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentChain !== ChainType.PROJECT_CHAIN && indexingState.isActive && showIndexingCard && (
|
||||
<>
|
||||
<Separator orientation="vertical" />
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { SearchBar } from "@/components/ui/SearchBar";
|
|||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChatIconWithAttention } from "@/components/chat-components/ChatIconWithAttention";
|
||||
import { logError } from "@/logger";
|
||||
import { useSettingsValue } from "@/settings/model";
|
||||
import { sortByStrategy } from "@/utils/recentUsageManager";
|
||||
|
|
@ -22,6 +23,15 @@ export interface ChatHistoryItem {
|
|||
/** Backend that produced this chat (Agent Mode only). Used to resolve a
|
||||
* brand icon in the popover via the caller-supplied `getIcon` resolver. */
|
||||
backendId?: string;
|
||||
/** Raw `projectId` from frontmatter, or `undefined` when absent. The
|
||||
* GLOBAL_SCOPE default is applied in the Agent Mode session layer, not here,
|
||||
* to keep this generic helper free of cross-layer scope imports. */
|
||||
projectId?: string;
|
||||
/** A live in-memory session bound to this chat is flagging for attention
|
||||
* (finished / errored / paused while backgrounded). In-memory only and valid
|
||||
* for the app's lifetime — purely-on-disk chats never carry it. Populated by
|
||||
* the Agent Mode session layer; absent on plain conversation history. */
|
||||
needsAttention?: boolean;
|
||||
}
|
||||
|
||||
type ChatHistoryIconResolver = (
|
||||
|
|
@ -402,7 +412,11 @@ function ChatHistoryItem({
|
|||
if (isEditing) {
|
||||
return (
|
||||
<div className="tw-flex tw-items-center tw-gap-2 tw-rounded-md tw-p-2">
|
||||
<RowIcon className="tw-size-3 tw-shrink-0 tw-text-muted" />
|
||||
<ChatIconWithAttention
|
||||
icon={RowIcon}
|
||||
needsAttention={chat.needsAttention}
|
||||
iconClassName="tw-size-3 tw-text-muted"
|
||||
/>
|
||||
<Input
|
||||
value={editingTitle}
|
||||
onChange={(e) => onEditingTitleChange(e.target.value)}
|
||||
|
|
@ -433,7 +447,11 @@ function ChatHistoryItem({
|
|||
)}
|
||||
onClick={() => onLoadChat(chat.id)}
|
||||
>
|
||||
<RowIcon className="tw-size-3 tw-shrink-0 tw-text-muted" />
|
||||
<ChatIconWithAttention
|
||||
icon={RowIcon}
|
||||
needsAttention={chat.needsAttention}
|
||||
iconClassName="tw-size-3 tw-text-muted"
|
||||
/>
|
||||
|
||||
<div className="tw-min-w-0 tw-flex-1">
|
||||
<span className="tw-block tw-truncate tw-text-sm tw-font-medium tw-text-normal">
|
||||
|
|
|
|||
38
src/components/chat-components/ChatIconWithAttention.tsx
Normal file
38
src/components/chat-components/ChatIconWithAttention.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ChatIconWithAttentionProps {
|
||||
/** Leading glyph (a lucide icon or backend brand icon). */
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
/** Overlay the accent dot — set when a live session for this chat needs attention. */
|
||||
needsAttention?: boolean;
|
||||
/** Class for the glyph itself (size + colour); the dot positions against it. */
|
||||
iconClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat-row icon with the same "needs attention" accent dot the agent tab strip's
|
||||
* `BrandIcon` paints, so a backgrounded session that finished / paused for
|
||||
* permission reads identically in the history list and on its tab. Accent-only
|
||||
* (the history list has no spinner / error-dot states). The wrapper owns
|
||||
* positioning; callers size the glyph via `iconClassName` (rows differ: `tw-size-3`
|
||||
* in the popover, `tw-size-4` inline).
|
||||
*/
|
||||
export const ChatIconWithAttention: React.FC<ChatIconWithAttentionProps> = ({
|
||||
icon: Icon,
|
||||
needsAttention,
|
||||
iconClassName,
|
||||
}) => (
|
||||
<span className="tw-relative tw-inline-flex tw-shrink-0 tw-items-center tw-justify-center">
|
||||
<Icon className={iconClassName} />
|
||||
{needsAttention && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"tw-absolute -tw-right-0.5 -tw-top-0.5 tw-size-1.5 tw-rounded-full tw-ring-1 tw-ring-[var(--background-primary)]",
|
||||
"tw-bg-interactive-accent"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
|
@ -129,6 +129,8 @@ export interface ChatInputProps {
|
|||
onRemoveSelectedText?: (id: string) => void;
|
||||
showProgressCard: () => void;
|
||||
showIndexingCard?: () => void;
|
||||
/** Agent Mode project-context status icon, rendered in the context badge row. */
|
||||
contextStatusIndicator?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Render slot for the toggle row that sits next to the send button.
|
||||
|
|
@ -224,6 +226,7 @@ const ChatInput = React.forwardRef<ChatInputHandle, ChatInputProps>(function Cha
|
|||
onRemoveSelectedText,
|
||||
showProgressCard,
|
||||
showIndexingCard,
|
||||
contextStatusIndicator,
|
||||
toolControls,
|
||||
onToolPillsChange,
|
||||
onTagSelected,
|
||||
|
|
@ -795,6 +798,8 @@ const ChatInput = React.forwardRef<ChatInputHandle, ChatInputProps>(function Cha
|
|||
onAddToContext={handleAddToContext}
|
||||
onRemoveFromContext={handleRemoveFromContext}
|
||||
hideAddContextButton={isAgentMode}
|
||||
statusIndicator={contextStatusIndicator}
|
||||
isAgentMode={isAgentMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ interface ChatControlsProps {
|
|||
onRemoveFromContext: (category: string, data: string) => void;
|
||||
|
||||
hideAddContextButton?: boolean;
|
||||
statusIndicator?: React.ReactNode;
|
||||
isAgentMode?: boolean;
|
||||
}
|
||||
|
||||
export const ContextControl: React.FC<ChatControlsProps> = ({
|
||||
|
|
@ -41,6 +43,8 @@ export const ContextControl: React.FC<ChatControlsProps> = ({
|
|||
onAddToContext,
|
||||
onRemoveFromContext,
|
||||
hideAddContextButton,
|
||||
statusIndicator,
|
||||
isAgentMode,
|
||||
}) => {
|
||||
const handleRemoveContext = (category: string, data: string) => {
|
||||
// Delegate to unified handler
|
||||
|
|
@ -74,6 +78,8 @@ export const ContextControl: React.FC<ChatControlsProps> = ({
|
|||
onTypeaheadSelect={handleTypeaheadSelect}
|
||||
lexicalEditorRef={lexicalEditorRef}
|
||||
hideAddContextButton={hideAddContextButton}
|
||||
statusIndicator={statusIndicator}
|
||||
isAgentMode={isAgentMode}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { cn } from "@/lib/utils";
|
|||
import { logError, logWarn } from "@/logger";
|
||||
import { ProjectFileManager } from "@/projects/ProjectFileManager";
|
||||
import { useSettingsValue } from "@/settings/model";
|
||||
import { RecentUsageManager, sortByStrategy } from "@/utils/recentUsageManager";
|
||||
import { sortByStrategy } from "@/utils/recentUsageManager";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ChevronDown,
|
||||
|
|
@ -34,33 +34,11 @@ import {
|
|||
} from "lucide-react";
|
||||
import { getCachedProjectRecordById } from "@/projects/state";
|
||||
import { App, Notice } from "obsidian";
|
||||
import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import React, { memo, useEffect, useMemo, useState } from "react";
|
||||
import { filterProjects } from "@/utils/projectUtils";
|
||||
import { useRecentUsageManagerRevision } from "@/hooks/useRecentUsageManagerRevision";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
/**
|
||||
* Subscribe to a {@link RecentUsageManager} revision so in-memory touches can trigger
|
||||
* re-sorting even when the backing list reference stays unchanged (e.g. when persistence
|
||||
* is throttled).
|
||||
*/
|
||||
function useRecentUsageManagerRevision<Key extends string>(
|
||||
manager: RecentUsageManager<Key> | null | undefined
|
||||
): number {
|
||||
const subscribe = useCallback(
|
||||
(cb: () => void) => manager?.subscribe(cb) ?? (() => {}),
|
||||
[manager]
|
||||
);
|
||||
const getSnapshot = useCallback(() => manager?.getRevision() ?? 0, [manager]);
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
}
|
||||
|
||||
function ProjectItem({
|
||||
project,
|
||||
loadContext,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,25 @@
|
|||
import { App, Component, MarkdownRenderer, Modal, Notice, setIcon } from "obsidian";
|
||||
import { logWarn } from "@/logger";
|
||||
import { renderMarkdown } from "@/utils/renderMarkdown";
|
||||
import { PREVIEW_RENDER_LIMIT, truncateForPreview } from "@/utils/truncateForPreview";
|
||||
import { App, Component, Modal, Notice, setIcon } from "obsidian";
|
||||
|
||||
/**
|
||||
* Read-only modal for previewing cached parsed file content.
|
||||
*
|
||||
* Features:
|
||||
* - Wider layout (90vw / max 800px) for comfortable reading
|
||||
* - Markdown rendering via Obsidian's MarkdownRenderer
|
||||
* - Markdown rendering via the project's `renderMarkdown` wrapper
|
||||
* - Caps the rendered length (see `truncateForPreview`) so large snapshots
|
||||
* don't freeze the UI; Copy always hands over the full, untruncated content
|
||||
* - Copy icon button with visual feedback (copy → check → copy)
|
||||
* - Scrollable content area with theme-aware styling
|
||||
*/
|
||||
export class CachePreviewModal extends Modal {
|
||||
private component: Component;
|
||||
/** Pending deferred-render frame, cancelled if the modal closes first. */
|
||||
private renderRafId?: number;
|
||||
/** The window the frame was scheduled on, so onClose cancels on the same one. */
|
||||
private renderRafWin?: Window;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
|
|
@ -31,6 +40,13 @@ export class CachePreviewModal extends Modal {
|
|||
contentEl.addClass("tw-flex", "tw-flex-col", "tw-p-0");
|
||||
this.component.load();
|
||||
|
||||
// Reason: cap the synchronous render so large snapshots don't freeze the UI.
|
||||
// Copy still hands over the full content below.
|
||||
const { text: previewText, truncated } = truncateForPreview(this.content);
|
||||
|
||||
// NOTE: `cls` strings here stay bare (no cn()) to match this file's existing
|
||||
// createDiv convention; cn() is for merging conditional JSX classNames, which
|
||||
// this imperative DOM construction doesn't need.
|
||||
// Header: file icon + title + copy button
|
||||
const header = contentEl.createDiv({
|
||||
cls: "tw-flex tw-items-center tw-justify-between tw-px-5 tw-py-3 tw-border-b tw-border-border",
|
||||
|
|
@ -53,39 +69,118 @@ export class CachePreviewModal extends Modal {
|
|||
});
|
||||
const copyIconEl = copyBtn.createSpan({ cls: "tw-flex tw-items-center" });
|
||||
setIcon(copyIconEl, "copy");
|
||||
this.bindCopyFullContent(copyBtn, copyIconEl);
|
||||
|
||||
copyBtn.addEventListener("click", () => {
|
||||
navigator.clipboard.writeText(this.content).then(
|
||||
() => {
|
||||
// Reason: visual feedback — icon changes to check mark for 2 seconds
|
||||
setIcon(copyIconEl, "check");
|
||||
copyBtn.addClass("tw-text-accent");
|
||||
new Notice("Copied to clipboard");
|
||||
window.setTimeout(() => {
|
||||
setIcon(copyIconEl, "copy");
|
||||
copyBtn.removeClass("tw-text-accent");
|
||||
}, 2000);
|
||||
},
|
||||
() => new Notice("Failed to copy")
|
||||
);
|
||||
});
|
||||
// Persistent banner when the preview was capped — keeps it visible above
|
||||
// the scroll area instead of buried at the bottom of long content.
|
||||
if (truncated) {
|
||||
const limitKb = Math.round(PREVIEW_RENDER_LIMIT / 1000);
|
||||
contentEl.createDiv({
|
||||
text: `Preview shows the first ${limitKb} KB for performance. Use Copy to get the full content.`,
|
||||
cls: "tw-px-5 tw-py-2 tw-text-sm tw-text-muted tw-border-b tw-border-border tw-bg-secondary",
|
||||
});
|
||||
}
|
||||
|
||||
// Content area: scrollable rendered markdown
|
||||
// Content area: scrollable rendered markdown. Truncated content always
|
||||
// fills the cap, so pin the height up front — the modal then opens at its
|
||||
// final size and the deferred render fills it without growing the window.
|
||||
// Small content keeps an auto height so the modal hugs its content.
|
||||
const scrollArea = contentEl.createDiv({
|
||||
cls: "tw-overflow-auto tw-p-5",
|
||||
attr: { style: "max-height: 50vh" },
|
||||
cls: truncated
|
||||
? "tw-h-[50vh] tw-overflow-auto tw-p-5"
|
||||
: "tw-max-h-[50vh] tw-overflow-auto tw-p-5",
|
||||
});
|
||||
|
||||
const mdContainer = scrollArea.createDiv({
|
||||
cls: "markdown-rendered tw-p-4 tw-bg-primary-alt tw-rounded-lg tw-border tw-border-border",
|
||||
});
|
||||
|
||||
// Reason: pass empty sourcePath to prevent vault link resolution
|
||||
void MarkdownRenderer.renderMarkdown(this.content, mdContainer, "", this.component);
|
||||
// Explicit "cut here" marker at the end of the rendered slice so a capped
|
||||
// preview reads as truncated, not as the natural end of the content. The
|
||||
// flanking divider lines break the body text flow, and the centered pill is
|
||||
// an actionable affordance (copy the full content without scrolling back up
|
||||
// to the header) — the GitHub "Load diff" / Carbon overflow pattern.
|
||||
if (truncated) {
|
||||
const endMarker = scrollArea.createDiv({
|
||||
cls: "tw-flex tw-items-center tw-gap-3 tw-pt-6 tw-pb-1",
|
||||
});
|
||||
endMarker.createDiv({ cls: "tw-flex-grow tw-border-t tw-border-border" });
|
||||
const fullCopyBtn = endMarker.createEl("button", {
|
||||
cls: "tw-flex tw-shrink-0 tw-items-center tw-gap-2 tw-px-4 tw-py-1.5 tw-rounded-full tw-bg-secondary tw-border tw-border-border tw-cursor-pointer tw-text-xs tw-font-medium tw-uppercase tw-tracking-wide tw-text-muted hover:tw-text-accent hover:tw-border-accent",
|
||||
attr: { "aria-label": "Copy full content", title: "Copy full content" },
|
||||
});
|
||||
const fullCopyIconEl = fullCopyBtn.createSpan({ cls: "tw-flex tw-items-center" });
|
||||
setIcon(fullCopyIconEl, "copy");
|
||||
fullCopyBtn.createSpan({ text: "Copy full content" });
|
||||
this.bindCopyFullContent(fullCopyBtn, fullCopyIconEl);
|
||||
endMarker.createDiv({ cls: "tw-flex-grow tw-border-t tw-border-border" });
|
||||
}
|
||||
|
||||
// Reason: pass empty sourcePath to prevent vault link resolution.
|
||||
const renderContent = (): void => {
|
||||
void renderMarkdown(this.app, previewText, mdContainer, "", this.component).catch(
|
||||
(error: unknown) => {
|
||||
logWarn("[CachePreviewModal] markdown render failed", error);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (truncated) {
|
||||
// Defer the heavy render so the (already full-size) shell paints first and
|
||||
// the window opens instantly instead of blocking onOpen until the large
|
||||
// snapshot is laid out. Two frames: paint the shell, then render.
|
||||
//
|
||||
// DESIGN NOTE — no "closed" guard around renderContent(): every caller does
|
||||
// `new CachePreviewModal(...).open()` (see cacheFileOpener.ts), so an
|
||||
// instance is never reused/reopened, and the heavy DOM work is effectively
|
||||
// non-interruptible once it starts — the user can't close mid-render. The
|
||||
// only residual case (async post-processors finishing after close) writes
|
||||
// to a detached, GC-able container with no observable effect. A generation
|
||||
// guard would defend a path no caller can reach.
|
||||
const win = this.contentEl.win ?? window;
|
||||
this.renderRafWin = win;
|
||||
this.renderRafId = win.requestAnimationFrame(() => {
|
||||
this.renderRafId = win.requestAnimationFrame(() => {
|
||||
this.renderRafId = undefined;
|
||||
renderContent();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
renderContent();
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
if (this.renderRafId !== undefined) {
|
||||
(this.renderRafWin ?? window).cancelAnimationFrame(this.renderRafId);
|
||||
this.renderRafId = undefined;
|
||||
}
|
||||
this.renderRafWin = undefined;
|
||||
this.component.unload();
|
||||
this.contentEl.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire a copy-to-clipboard button that always copies the FULL content (never
|
||||
* the truncated preview), with icon feedback (copy → check → copy). Shared by
|
||||
* the header button and the truncation marker's "Copy full content" pill.
|
||||
*/
|
||||
private bindCopyFullContent(button: HTMLElement, iconEl: HTMLElement): void {
|
||||
button.addEventListener("click", () => {
|
||||
navigator.clipboard.writeText(this.content).then(
|
||||
() => {
|
||||
// Reason: visual feedback — icon changes to a check mark for 2 seconds
|
||||
setIcon(iconEl, "check");
|
||||
button.addClass("tw-text-accent");
|
||||
new Notice("Copied to clipboard");
|
||||
// Use the button's own window so the reset fires correctly in popouts.
|
||||
(button.win ?? window).setTimeout(() => {
|
||||
setIcon(iconEl, "copy");
|
||||
button.removeClass("tw-text-accent");
|
||||
}, 2000);
|
||||
},
|
||||
() => new Notice("Failed to copy")
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { UrlTagInput } from "@/components/ui/url-tag-input";
|
|||
import { SystemPromptSyntaxInstruction } from "@/components/SystemPromptSyntaxInstruction";
|
||||
import { DEFAULT_MODEL_SETTING } from "@/constants";
|
||||
import { ProjectContextBadgeList } from "@/components/project/ProjectContextBadgeList";
|
||||
import { ProjectContextSourceEditor } from "@/components/project/ProjectContextSourceEditor";
|
||||
import { err2String, randomUUID } from "@/utils";
|
||||
import { Settings } from "lucide-react";
|
||||
import { type UrlItem, parseProjectUrls, serializeProjectUrls } from "@/utils/urlTagUtils";
|
||||
|
|
@ -32,6 +33,26 @@ interface AddProjectModalContentProps {
|
|||
onSave: (project: ProjectConfig) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
plugin?: CopilotPlugin;
|
||||
/**
|
||||
* Agent Mode variant: hides the Model Configuration card and the CAG
|
||||
* processing/cache status (Agent projects have no model selector — see
|
||||
* `ProjectConfig.projectModelKey` — and materialize context at session start
|
||||
* rather than via the CAG vector pipeline), and drops the model from the
|
||||
* required-field validation. Existing `projectModelKey`/`modelConfigs` values
|
||||
* are preserved untouched for CAG compatibility.
|
||||
*
|
||||
* DESIGN NOTE — one flagged shell, not two modals. CAG and Agent share the
|
||||
* same project shape (name, context source, URLs, save/validate flow); the
|
||||
* only divergence is the model card and which status surface is shown. A
|
||||
* dedicated Agent modal would duplicate the save/validate/context-editor
|
||||
* plumbing to fork two render branches, so the shared shell is intentional.
|
||||
* If Agent grows project fields CAG never has (forking the save/validate
|
||||
* shape itself), split then.
|
||||
*/
|
||||
agentMode?: boolean;
|
||||
/** Portal target for the context editor's +URL popover — the modal's own
|
||||
* `contentEl`, so the popover (layer 30) stacks above this modal (layer 50). */
|
||||
popoverContainer?: HTMLElement | null;
|
||||
}
|
||||
|
||||
function AddProjectModalContent({
|
||||
|
|
@ -39,6 +60,8 @@ function AddProjectModalContent({
|
|||
onSave,
|
||||
onCancel,
|
||||
plugin,
|
||||
agentMode = false,
|
||||
popoverContainer,
|
||||
}: AddProjectModalContentProps) {
|
||||
const app = useApp();
|
||||
// Project model options come from the model-management "chat" backend
|
||||
|
|
@ -56,8 +79,11 @@ function AddProjectModalContent({
|
|||
initialProject
|
||||
? {
|
||||
...initialProject,
|
||||
projectModelKey:
|
||||
resolveSelectionId(initialProject.projectModelKey) || initialProject.projectModelKey,
|
||||
// Agent Mode hides the model card, so don't resolve/migrate the
|
||||
// (invisible) projectModelKey — preserve it verbatim. CAG still resolves.
|
||||
projectModelKey: agentMode
|
||||
? initialProject.projectModelKey
|
||||
: resolveSelectionId(initialProject.projectModelKey) || initialProject.projectModelKey,
|
||||
}
|
||||
: {
|
||||
id: randomUUID(),
|
||||
|
|
@ -92,35 +118,45 @@ function AddProjectModalContent({
|
|||
|
||||
// Reason: Shared hook handles cache loading, file enumeration, and processingData construction.
|
||||
// contextSource draft is passed so newly added (unsaved) URLs appear as "Pending".
|
||||
// Agent Mode hides the CAG processing/cache UI, so skip the cache read + vault
|
||||
// file enumeration entirely (null cacheProject no-ops the expensive work).
|
||||
const { processingData, projectCache, isCurrentProject } = useProjectProcessingData({
|
||||
cacheProject: initialProject ?? null,
|
||||
contextSource: formData.contextSource,
|
||||
cacheProject: agentMode ? null : (initialProject ?? null),
|
||||
contextSource: agentMode ? undefined : formData.contextSource,
|
||||
});
|
||||
|
||||
const handleEditProjectContext = (projectDraft: ProjectConfig) => {
|
||||
const modal = new ContextManageModal(
|
||||
app,
|
||||
(updatedProject: ProjectConfig) => {
|
||||
// Reason: Only merge inclusions/exclusions (what ContextManageModal edits).
|
||||
// Don't replace the entire contextSource — that would overwrite any
|
||||
// webUrls/youtubeUrls changes the user made in AddProjectModal while
|
||||
// the child modal was open.
|
||||
// Merge back what the modal edited. CAG (enableLinks off) edits only
|
||||
// inclusions/exclusions, so URLs are left to the parent's own editor and
|
||||
// not clobbered. Agent Mode (enableLinks on) also edits URLs, so merge
|
||||
// those too — otherwise the user's Manage URL changes would be dropped.
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
contextSource: {
|
||||
...prev.contextSource,
|
||||
inclusions: updatedProject.contextSource?.inclusions,
|
||||
exclusions: updatedProject.contextSource?.exclusions,
|
||||
...(agentMode
|
||||
? {
|
||||
webUrls: updatedProject.contextSource?.webUrls,
|
||||
youtubeUrls: updatedProject.contextSource?.youtubeUrls,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}));
|
||||
},
|
||||
projectDraft
|
||||
projectDraft,
|
||||
{ enableLinks: agentMode }
|
||||
);
|
||||
modal.open();
|
||||
};
|
||||
|
||||
const isFormValid = () => {
|
||||
return formData.name && formData.projectModelKey;
|
||||
// Agent projects have no model selector, so only the name is required.
|
||||
return formData.name && (agentMode || formData.projectModelKey);
|
||||
};
|
||||
|
||||
const handleInputChange = (
|
||||
|
|
@ -175,6 +211,16 @@ function AddProjectModalContent({
|
|||
handleInputChange("contextSource.youtubeUrls", youtubeUrls);
|
||||
};
|
||||
|
||||
/** Agent Mode: apply a context-source patch from the shared editor into the
|
||||
* form draft. Persisted only on Save — the modal keeps draft (Cancel/Save)
|
||||
* semantics, unlike the home section's immediate write. */
|
||||
const handleContextChange = (patch: Partial<NonNullable<ProjectConfig["contextSource"]>>) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
contextSource: { ...prev.contextSource, ...patch },
|
||||
}));
|
||||
};
|
||||
|
||||
/** Handle inclusions pattern changes from badge list deletion */
|
||||
const handleInclusionsChange = (value: string) => {
|
||||
handleInputChange("contextSource.inclusions", value);
|
||||
|
|
@ -212,9 +258,19 @@ function AddProjectModalContent({
|
|||
|
||||
const handleSave = async () => {
|
||||
const trimmedName = formData.name?.trim() ?? "";
|
||||
const saveData = { ...formData, name: trimmedName };
|
||||
// Agent Mode hides the model card; never let an Agent edit persist a changed
|
||||
// projectModelKey/modelConfigs the user couldn't see — restore them verbatim.
|
||||
const saveData =
|
||||
agentMode && initialProject
|
||||
? {
|
||||
...formData,
|
||||
name: trimmedName,
|
||||
projectModelKey: initialProject.projectModelKey,
|
||||
modelConfigs: initialProject.modelConfigs,
|
||||
}
|
||||
: { ...formData, name: trimmedName };
|
||||
|
||||
const requiredFields = ["name", "projectModelKey"];
|
||||
const requiredFields = agentMode ? ["name"] : ["name", "projectModelKey"];
|
||||
const missingFields = requiredFields.filter((field) => !saveData[field as keyof ProjectConfig]);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
|
|
@ -290,132 +346,158 @@ function AddProjectModalContent({
|
|||
label="Project System Prompt"
|
||||
description="Custom instructions for how the AI should behave in this project context"
|
||||
>
|
||||
<SystemPromptSyntaxInstruction />
|
||||
{/* Template variables ({activeNote}, {[[Note]]}, …) are expanded by
|
||||
legacy chat's prompt processor only; agent backends receive the
|
||||
prompt verbatim, so the hints would mislead in Agent Mode. */}
|
||||
{!agentMode && <SystemPromptSyntaxInstruction />}
|
||||
<Textarea
|
||||
value={formData.systemPrompt}
|
||||
onChange={(e) => handleInputChange("systemPrompt", e.target.value)}
|
||||
onBlur={() => setTouched((prev) => ({ ...prev, systemPrompt: true }))}
|
||||
placeholder="Enter your project system prompt here... Use {[[Note Name]]} to include note contents."
|
||||
placeholder={
|
||||
agentMode
|
||||
? "Enter your project system prompt here..."
|
||||
: "Enter your project system prompt here... Use {[[Note Name]]} to include note contents."
|
||||
}
|
||||
className="tw-min-h-32"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Configuration Card */}
|
||||
<div className="tw-rounded-lg tw-border tw-border-border tw-p-4 tw-bg-secondary/50">
|
||||
<h3 className="tw-mb-3 tw-text-sm tw-font-medium tw-text-normal">
|
||||
Model Configuration
|
||||
</h3>
|
||||
<div className="tw-flex tw-flex-col tw-gap-3">
|
||||
<FormField
|
||||
label="Default Model"
|
||||
required
|
||||
error={touched.projectModelKey && !formData.projectModelKey}
|
||||
errorMessage="Default model is required"
|
||||
>
|
||||
<ObsidianNativeSelect
|
||||
value={formData.projectModelKey}
|
||||
onChange={(e) => handleInputChange("projectModelKey", e.target.value)}
|
||||
onBlur={() => setTouched((prev) => ({ ...prev, projectModelKey: true }))}
|
||||
placeholder="Select a model"
|
||||
options={chatModelOptions}
|
||||
/>
|
||||
</FormField>
|
||||
{/* Model Configuration Card — hidden in Agent Mode (no model selector). */}
|
||||
{!agentMode && (
|
||||
<div className="tw-rounded-lg tw-border tw-border-border tw-p-4 tw-bg-secondary/50">
|
||||
<h3 className="tw-mb-3 tw-text-sm tw-font-medium tw-text-normal">
|
||||
Model Configuration
|
||||
</h3>
|
||||
<div className="tw-flex tw-flex-col tw-gap-3">
|
||||
<FormField
|
||||
label="Default Model"
|
||||
required
|
||||
error={touched.projectModelKey && !formData.projectModelKey}
|
||||
errorMessage="Default model is required"
|
||||
>
|
||||
<ObsidianNativeSelect
|
||||
value={formData.projectModelKey}
|
||||
onChange={(e) => handleInputChange("projectModelKey", e.target.value)}
|
||||
onBlur={() => setTouched((prev) => ({ ...prev, projectModelKey: true }))}
|
||||
placeholder="Select a model"
|
||||
options={chatModelOptions}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Temperature">
|
||||
<SettingSlider
|
||||
value={formData.modelConfigs?.temperature ?? DEFAULT_MODEL_SETTING.TEMPERATURE}
|
||||
onChange={(value) => handleInputChange("modelConfigs.temperature", value)}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.01}
|
||||
className="tw-w-full"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Temperature">
|
||||
<SettingSlider
|
||||
value={formData.modelConfigs?.temperature ?? DEFAULT_MODEL_SETTING.TEMPERATURE}
|
||||
onChange={(value) => handleInputChange("modelConfigs.temperature", value)}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.01}
|
||||
className="tw-w-full"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Token Limit">
|
||||
<SettingSlider
|
||||
value={formData.modelConfigs?.maxTokens ?? DEFAULT_MODEL_SETTING.MAX_TOKENS}
|
||||
onChange={(value) => handleInputChange("modelConfigs.maxTokens", value)}
|
||||
min={1}
|
||||
max={65000}
|
||||
step={1}
|
||||
className="tw-w-full"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Token Limit">
|
||||
<SettingSlider
|
||||
value={formData.modelConfigs?.maxTokens ?? DEFAULT_MODEL_SETTING.MAX_TOKENS}
|
||||
onChange={(value) => handleInputChange("modelConfigs.maxTokens", value)}
|
||||
min={1}
|
||||
max={65000}
|
||||
step={1}
|
||||
className="tw-w-full"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context Sources Card */}
|
||||
<div className="tw-rounded-lg tw-border tw-border-border tw-p-4 tw-bg-secondary/50">
|
||||
<h3 className="tw-mb-3 tw-text-sm tw-font-medium tw-text-normal">Context Sources</h3>
|
||||
<div className="tw-flex tw-flex-col tw-gap-4">
|
||||
{/* File Context Sub-card */}
|
||||
<div className="tw-rounded-lg tw-border tw-border-border tw-p-4">
|
||||
<FormField
|
||||
label={
|
||||
<div className="tw-flex tw-items-center tw-gap-2">
|
||||
<span>File Context</span>
|
||||
<HelpTooltip
|
||||
buttonClassName="tw-size-4 tw-text-muted"
|
||||
content={
|
||||
<div className="tw-max-w-80">
|
||||
<strong>Supported File Types:</strong>
|
||||
<br />
|
||||
<strong>• Documents:</strong> pdf, doc, docx, ppt, pptx, epub, txt, rtf
|
||||
and many more
|
||||
<br />
|
||||
<strong>• Images:</strong> jpg, png, svg, gif, bmp, webp, tiff
|
||||
<br />
|
||||
<strong>• Spreadsheets:</strong> xlsx, xls, csv, numbers
|
||||
<br />
|
||||
<br />
|
||||
Non-markdown files are converted to markdown in the background.
|
||||
<br />
|
||||
<strong>Rate limit:</strong> 50 files or 100MB per 3 hours, whichever is
|
||||
reached first.
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
description="Define patterns to include specific files, folders or tags (specified in the note property) in the project context."
|
||||
>
|
||||
<ProjectContextBadgeList
|
||||
inclusions={formData.contextSource?.inclusions}
|
||||
exclusions={formData.contextSource?.exclusions}
|
||||
onInclusionsChange={handleInclusionsChange}
|
||||
onExclusionsChange={handleExclusionsChange}
|
||||
actionSlot={
|
||||
<Button
|
||||
size="lg"
|
||||
className="tw-h-9 tw-gap-1 tw-px-3 sm:tw-h-auto sm:tw-px-2"
|
||||
onClick={() => handleEditProjectContext(formData)}
|
||||
>
|
||||
<Settings className="tw-size-4 sm:tw-size-3.5" />
|
||||
Manage Context
|
||||
</Button>
|
||||
{agentMode ? (
|
||||
// Agent Mode: the same mixed file+URL editor as the project landing,
|
||||
// wired to the form draft (Save commits). CAG keeps its split sub-cards.
|
||||
<ProjectContextSourceEditor
|
||||
contextSource={formData.contextSource}
|
||||
onChange={handleContextChange}
|
||||
onManage={() => handleEditProjectContext(formData)}
|
||||
popoverContainer={popoverContainer}
|
||||
droppable={false}
|
||||
solidManageButton
|
||||
showHelperText
|
||||
/>
|
||||
) : (
|
||||
<div className="tw-flex tw-flex-col tw-gap-4">
|
||||
{/* File Context Sub-card */}
|
||||
<div className="tw-rounded-lg tw-border tw-border-border tw-p-4">
|
||||
<FormField
|
||||
label={
|
||||
<div className="tw-flex tw-items-center tw-gap-2">
|
||||
<span>File Context</span>
|
||||
<HelpTooltip
|
||||
buttonClassName="tw-size-4 tw-text-muted"
|
||||
content={
|
||||
<div className="tw-max-w-80">
|
||||
<strong>Supported File Types:</strong>
|
||||
<br />
|
||||
<strong>• Documents:</strong> pdf, doc, docx, ppt, pptx, epub, txt,
|
||||
rtf and many more
|
||||
<br />
|
||||
<strong>• Images:</strong> jpg, png, svg, gif, bmp, webp, tiff
|
||||
<br />
|
||||
<strong>• Spreadsheets:</strong> xlsx, xls, csv, numbers
|
||||
<br />
|
||||
<br />
|
||||
Non-markdown files are converted to markdown in the background.
|
||||
<br />
|
||||
<strong>Rate limit:</strong> 50 files or 100MB per 3 hours, whichever
|
||||
is reached first.
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{/* URLs Sub-card */}
|
||||
<div className="tw-rounded-lg tw-border tw-border-border tw-p-4">
|
||||
<div className="tw-mb-3">
|
||||
<span className="tw-text-sm tw-font-medium tw-text-normal">URLs</span>
|
||||
<p className="tw-mt-1 tw-text-ui-smaller tw-text-muted">
|
||||
Add web pages or YouTube videos as context sources
|
||||
</p>
|
||||
description="Define patterns to include specific files, folders or tags (specified in the note property) in the project context."
|
||||
>
|
||||
<ProjectContextBadgeList
|
||||
inclusions={formData.contextSource?.inclusions}
|
||||
exclusions={formData.contextSource?.exclusions}
|
||||
onInclusionsChange={handleInclusionsChange}
|
||||
onExclusionsChange={handleExclusionsChange}
|
||||
actionSlot={
|
||||
<Button
|
||||
size="lg"
|
||||
className="tw-h-9 tw-gap-1 tw-px-3 sm:tw-h-auto sm:tw-px-2"
|
||||
onClick={() => handleEditProjectContext(formData)}
|
||||
>
|
||||
<Settings className="tw-size-4 sm:tw-size-3.5" />
|
||||
Manage Context
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{/* URLs Sub-card */}
|
||||
<div className="tw-rounded-lg tw-border tw-border-border tw-p-4">
|
||||
<div className="tw-mb-3">
|
||||
<span className="tw-text-sm tw-font-medium tw-text-normal">URLs</span>
|
||||
<p className="tw-mt-1 tw-text-ui-smaller tw-text-muted">
|
||||
Add web pages or YouTube videos as context sources
|
||||
</p>
|
||||
</div>
|
||||
<UrlTagInput urls={urlItems} onAdd={handleUrlAdd} onRemove={handleUrlRemove} />
|
||||
</div>
|
||||
<UrlTagInput urls={urlItems} onAdd={handleUrlAdd} onRemove={handleUrlRemove} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Processing Status - show for any project in edit mode (active: live state; others: cache state) */}
|
||||
{initialProject && processingData && (
|
||||
{/* Processing Status — the CAG variant reads the CAG cache; the agent
|
||||
variant reads the agent pipeline's own data (load atom + off-vault
|
||||
conversion cache). Retry/open-cache stay CAG-only here: the agent
|
||||
variant's retry lives in the composer's context status popover. */}
|
||||
{!agentMode && initialProject && processingData && (
|
||||
<ProcessingStatus
|
||||
items={processingData.items}
|
||||
onRetry={isCurrentProject ? handleRetry : undefined}
|
||||
|
|
@ -450,7 +532,9 @@ export class AddProjectModal extends Modal {
|
|||
app: App,
|
||||
private onSave: (project: ProjectConfig) => Promise<void>,
|
||||
private initialProject?: ProjectConfig,
|
||||
private plugin?: CopilotPlugin
|
||||
private plugin?: CopilotPlugin,
|
||||
/** Agent Mode variant — see {@link AddProjectModalContentProps.agentMode}. */
|
||||
private agentMode: boolean = false
|
||||
) {
|
||||
super(app);
|
||||
}
|
||||
|
|
@ -478,6 +562,8 @@ export class AddProjectModal extends Modal {
|
|||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
plugin={this.plugin}
|
||||
agentMode={this.agentMode}
|
||||
popoverContainer={contentEl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
243
src/components/modals/project/ContextManageLinksPanel.tsx
Normal file
243
src/components/modals/project/ContextManageLinksPanel.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import type { ProcessingItem } from "@/components/project/processingAdapter";
|
||||
import {
|
||||
ProcessingStatusIcon,
|
||||
processingSourceKey,
|
||||
} from "@/components/project/processingItemStatusView";
|
||||
import { AddUrlPopover } from "@/components/project/AddUrlPopover";
|
||||
import { UrlTypeIcon } from "@/components/project/UrlTypeIcon";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { openAgentCachedItemPreview } from "@/utils/cacheFileOpener";
|
||||
import type { UrlItem, UrlKind } from "@/utils/urlTagUtils";
|
||||
import { ArrowUpRight, Globe, Link, PlusCircle, X, Youtube } from "lucide-react";
|
||||
import { App } from "obsidian";
|
||||
import React from "react";
|
||||
|
||||
/** The three Links-related selections in the Manage sidebar. */
|
||||
export type LinksSection = "links" | "web" | "youtube";
|
||||
|
||||
interface LinksSidebarSectionProps {
|
||||
activeSection: string | null;
|
||||
webCount: number;
|
||||
youtubeCount: number;
|
||||
onSelect: (section: LinksSection) => void;
|
||||
/** Saved URLs so the +URL popover dedups re-adds. */
|
||||
existingUrls: string[];
|
||||
/** Parsed, deduped URLs from the +URL popover → merge into the draft. */
|
||||
onAddUrls: (urls: UrlItem[]) => void;
|
||||
/** Portal target for the +URL popover (the Manage modal's contentEl). */
|
||||
popoverContainer?: HTMLElement | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Left-sidebar "Links" group (design M): a cyan parent "Links" + Web / YouTube
|
||||
* children with counts. Clicking the parent lists both groups on the right;
|
||||
* clicking a child filters to that one. Mirrors the existing file sections'
|
||||
* look (hover + active highlight) without reaching into the modal's private
|
||||
* SectionHeader.
|
||||
*/
|
||||
export function LinksSidebarSection({
|
||||
activeSection,
|
||||
webCount,
|
||||
youtubeCount,
|
||||
onSelect,
|
||||
existingUrls,
|
||||
onAddUrls,
|
||||
popoverContainer,
|
||||
}: LinksSidebarSectionProps) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"tw-mb-1 tw-flex tw-cursor-pointer tw-items-center tw-rounded-md tw-p-2 hover:tw-bg-secondary/50",
|
||||
activeSection === "links" && "tw-bg-secondary"
|
||||
)}
|
||||
onClick={() => onSelect("links")}
|
||||
>
|
||||
<Link className="tw-mr-2 tw-size-4 tw-text-context-manager-cyan" />
|
||||
<h3 className="tw-text-sm tw-font-semibold tw-text-context-manager-cyan">Links</h3>
|
||||
<AddUrlPopover
|
||||
existingUrls={existingUrls}
|
||||
onAdd={onAddUrls}
|
||||
container={popoverContainer}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="fit"
|
||||
className="tw-ml-auto tw-text-muted hover:tw-bg-secondary"
|
||||
title="Add link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<PlusCircle className="tw-size-4 tw-text-context-manager-cyan" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<LinkSubItem
|
||||
Icon={Globe}
|
||||
label="Web"
|
||||
count={webCount}
|
||||
active={activeSection === "web"}
|
||||
onClick={() => onSelect("web")}
|
||||
/>
|
||||
<LinkSubItem
|
||||
Icon={Youtube}
|
||||
label="YouTube"
|
||||
count={youtubeCount}
|
||||
active={activeSection === "youtube"}
|
||||
onClick={() => onSelect("youtube")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkSubItem({
|
||||
Icon,
|
||||
label,
|
||||
count,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
Icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
count: number;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"tw-flex tw-cursor-pointer tw-items-center tw-gap-2 tw-rounded-md tw-py-1.5 tw-pl-6 tw-pr-2 tw-text-sm hover:tw-bg-secondary/50",
|
||||
active && "tw-bg-secondary tw-text-normal"
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon className="tw-size-4 tw-text-context-manager-cyan" />
|
||||
<span className="tw-flex-1">{label}</span>
|
||||
<span className="tw-text-xs tw-text-faint">{count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LinksContentPanelProps {
|
||||
app: App;
|
||||
urlItems: UrlItem[];
|
||||
filter: LinksSection;
|
||||
/** Agent conversion status by {@link processingSourceKey}, supplied by the
|
||||
* modal (one shared lookup across Links + File Context). */
|
||||
agentProcessingByKey: ReadonlyMap<string, ProcessingItem>;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-pane Links viewer: the saved URLs grouped under Web / YouTube labels.
|
||||
* Each row shows its conversion status badge + a preview arrow (converted
|
||||
* snapshot) + a hover delete. Adding is handled solely by the sidebar's "+"
|
||||
* popover, matching every other context type (Tags / Folders / Files), whose
|
||||
* right pane is a pure list with no inline add affordance.
|
||||
*
|
||||
* Status reflects the SAVED config — a freshly added (unsaved) URL has no status
|
||||
* yet. The status lookup is passed in (not derived here) so the URL rows and the
|
||||
* File Context list share ONE {@link ProcessingStatusIcon} judgment.
|
||||
*/
|
||||
export function LinksContentPanel({
|
||||
app,
|
||||
urlItems,
|
||||
filter,
|
||||
agentProcessingByKey,
|
||||
onRemove,
|
||||
}: LinksContentPanelProps) {
|
||||
const handlePreview = (item: ProcessingItem) => {
|
||||
// Snapshots are off-vault and keyed by source identity, so the preview no
|
||||
// longer needs the project folder — just the item's kind + id.
|
||||
void openAgentCachedItemPreview(app, item);
|
||||
};
|
||||
|
||||
const webItems = urlItems.filter((u) => u.type === "web");
|
||||
const youtubeItems = urlItems.filter((u) => u.type === "youtube");
|
||||
const showWeb = filter === "links" || filter === "web";
|
||||
const showYoutube = filter === "links" || filter === "youtube";
|
||||
|
||||
return (
|
||||
<div className="tw-flex tw-flex-col tw-gap-2">
|
||||
{showWeb && webItems.length > 0 && (
|
||||
<UrlGroup
|
||||
label="Web"
|
||||
type="web"
|
||||
items={webItems}
|
||||
agentProcessingByKey={agentProcessingByKey}
|
||||
onRemove={onRemove}
|
||||
onPreview={handlePreview}
|
||||
/>
|
||||
)}
|
||||
{showYoutube && youtubeItems.length > 0 && (
|
||||
<UrlGroup
|
||||
label="YouTube"
|
||||
type="youtube"
|
||||
items={youtubeItems}
|
||||
agentProcessingByKey={agentProcessingByKey}
|
||||
onRemove={onRemove}
|
||||
onPreview={handlePreview}
|
||||
/>
|
||||
)}
|
||||
{((showWeb && webItems.length > 0) || (showYoutube && youtubeItems.length > 0)) === false && (
|
||||
<div className="tw-py-6 tw-text-center tw-text-sm tw-text-muted">
|
||||
No links yet. Use the + next to Links in the sidebar to add one.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UrlGroup({
|
||||
label,
|
||||
type,
|
||||
items,
|
||||
agentProcessingByKey,
|
||||
onRemove,
|
||||
onPreview,
|
||||
}: {
|
||||
label: string;
|
||||
type: UrlKind;
|
||||
items: UrlItem[];
|
||||
agentProcessingByKey: ReadonlyMap<string, ProcessingItem>;
|
||||
onRemove: (id: string) => void;
|
||||
onPreview: (item: ProcessingItem) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="tw-mb-2">
|
||||
<div className="tw-mb-1.5 tw-mt-2 tw-flex tw-items-center tw-gap-1.5 tw-text-xs tw-font-bold tw-text-faint">
|
||||
<UrlTypeIcon type={type} className="tw-size-3.5" />
|
||||
{label} <span className="tw-font-medium">({items.length})</span>
|
||||
</div>
|
||||
{items.map((item) => {
|
||||
const status = agentProcessingByKey.get(processingSourceKey(type, item.url));
|
||||
const isReady = status?.status === "ready";
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="tw-group tw-mb-1.5 tw-flex tw-items-center tw-gap-2.5 tw-rounded-lg tw-border tw-border-solid tw-border-border tw-px-3 tw-py-2"
|
||||
>
|
||||
<UrlTypeIcon type={item.type} className="tw-size-4 tw-shrink-0" />
|
||||
<TruncatedText className="tw-min-w-0 tw-flex-1 tw-text-sm" tooltipContent={item.url}>
|
||||
{item.url.replace(/^https?:\/\//, "")}
|
||||
</TruncatedText>
|
||||
{isReady && status && (
|
||||
<ArrowUpRight
|
||||
className="tw-size-4 tw-shrink-0 tw-cursor-pointer tw-text-faint tw-opacity-0 hover:tw-text-normal group-hover:tw-opacity-100"
|
||||
onClick={() => onPreview(status)}
|
||||
aria-label="View converted content"
|
||||
/>
|
||||
)}
|
||||
{status && <ProcessingStatusIcon item={status} revealReadyOnHover />}
|
||||
<X
|
||||
className="tw-size-4 tw-shrink-0 tw-cursor-pointer tw-text-faint tw-opacity-0 hover:tw-text-error group-hover:tw-opacity-100"
|
||||
onClick={() => onRemove(item.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,14 @@
|
|||
import { FailedItem, ProjectConfig, useProjectContextLoad, getCurrentProject } from "@/aiParams";
|
||||
import { ContextCache, ProjectContextCache } from "@/cache/projectContextCache";
|
||||
import { FolderSearchModal } from "@/components/modals/FolderSearchModal";
|
||||
import { openCachedProjectFile } from "@/utils/cacheFileOpener";
|
||||
import type { ProcessingItem } from "@/components/project/processingAdapter";
|
||||
import {
|
||||
buildProcessingItemLookup,
|
||||
ProcessingStatusIcon,
|
||||
processingSourceKey,
|
||||
} from "@/components/project/processingItemStatusView";
|
||||
import { useAgentProcessingItems } from "@/components/project/useAgentProcessingItems";
|
||||
import { openAgentCachedItemPreview, openCachedProjectFile } from "@/utils/cacheFileOpener";
|
||||
import { ProjectFileSelectModal } from "@/components/modals/ProjectFileSelectModal";
|
||||
import { TagSearchModal } from "@/components/modals/TagSearchModal";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
|
|
@ -41,6 +48,12 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|||
import { Root } from "react-dom/client";
|
||||
import { HelpTooltip } from "@/components/ui/help-tooltip";
|
||||
import { createPluginRoot } from "@/utils/react/createPluginRoot";
|
||||
import {
|
||||
LinksContentPanel,
|
||||
LinksSidebarSection,
|
||||
} from "@/components/modals/project/ContextManageLinksPanel";
|
||||
import { useContextUrls } from "@/components/modals/project/useContextUrls";
|
||||
import { UrlTypeIcon } from "@/components/project/UrlTypeIcon";
|
||||
|
||||
function FileIcon({ extension, size = "tw-size-4" }: { extension: string; size?: string }) {
|
||||
const ext = extension.toLowerCase().replace("*.", "");
|
||||
|
|
@ -61,7 +74,17 @@ interface ParsedQuery {
|
|||
extensions: string[];
|
||||
}
|
||||
|
||||
type ActiveSection = "tags" | "folders" | "files" | "extensions" | "ignoreFiles" | "search" | null;
|
||||
type ActiveSection =
|
||||
| "tags"
|
||||
| "folders"
|
||||
| "files"
|
||||
| "extensions"
|
||||
| "ignoreFiles"
|
||||
| "search"
|
||||
| "links"
|
||||
| "web"
|
||||
| "youtube"
|
||||
| null;
|
||||
type ActiveItem = string | null;
|
||||
|
||||
interface SectionHeaderProps {
|
||||
|
|
@ -70,6 +93,9 @@ interface SectionHeaderProps {
|
|||
iconColorClassName: string;
|
||||
onAddClick: () => void;
|
||||
tooltip?: string;
|
||||
/** When provided, the title (icon + label) is clickable — lists the whole
|
||||
* category on the right (agent Links variant). Omitted for CAG → not clickable. */
|
||||
onTitleClick?: () => void;
|
||||
}
|
||||
|
||||
const SectionHeader: React.FC<SectionHeaderProps> = ({
|
||||
|
|
@ -78,17 +104,28 @@ const SectionHeader: React.FC<SectionHeaderProps> = ({
|
|||
iconColorClassName,
|
||||
onAddClick,
|
||||
tooltip,
|
||||
onTitleClick,
|
||||
}) => {
|
||||
return (
|
||||
<div className="tw-mb-3 tw-flex tw-items-center tw-justify-between">
|
||||
<div className="tw-flex tw-items-center">
|
||||
<div
|
||||
className={cn(
|
||||
"tw-flex tw-items-center",
|
||||
onTitleClick &&
|
||||
"tw-cursor-pointer tw-rounded-md tw-px-1 tw-py-0.5 hover:tw-bg-secondary/50"
|
||||
)}
|
||||
onClick={onTitleClick}
|
||||
>
|
||||
<IconComponent className={`tw-mr-2 tw-size-4 ${iconColorClassName}`} />
|
||||
<h3 className={`tw-text-sm tw-font-semibold ${iconColorClassName}`}>{title}</h3>
|
||||
{tooltip && (
|
||||
<HelpTooltip
|
||||
buttonClassName="tw-ml-2 tw-size-4 tw-text-muted"
|
||||
content={<div className="tw-max-w-80">{tooltip}</div>}
|
||||
/>
|
||||
// Stop the tooltip click from bubbling to the (agent-clickable) title.
|
||||
<span onClick={(e) => e.stopPropagation()}>
|
||||
<HelpTooltip
|
||||
buttonClassName="tw-ml-2 tw-size-4 tw-text-muted"
|
||||
content={<div className="tw-max-w-80">{tooltip}</div>}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -124,6 +161,8 @@ interface SectionListProps {
|
|||
onAddClick: () => void;
|
||||
onDeleteItem: (e: React.MouseEvent, item: SectionItem) => void;
|
||||
tooltip?: string;
|
||||
/** Forwarded to the header's title click (agent Links variant). */
|
||||
onSectionClick?: () => void;
|
||||
}
|
||||
|
||||
const SectionList: React.FC<SectionListProps> = ({
|
||||
|
|
@ -139,6 +178,7 @@ const SectionList: React.FC<SectionListProps> = ({
|
|||
onAddClick,
|
||||
onDeleteItem,
|
||||
tooltip,
|
||||
onSectionClick,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -148,6 +188,7 @@ const SectionList: React.FC<SectionListProps> = ({
|
|||
iconColorClassName={iconColorClassName}
|
||||
onAddClick={onAddClick}
|
||||
tooltip={tooltip}
|
||||
onTitleClick={onSectionClick}
|
||||
/>
|
||||
<div className="tw-space-y-1">
|
||||
{items.map((item) => (
|
||||
|
|
@ -249,6 +290,15 @@ const STATUS_LABELS: Record<ProjectContextItemStatus, string> = {
|
|||
notStarted: "Not started",
|
||||
};
|
||||
|
||||
/** Status → text color for the CAG badge. (The agent variant renders status via
|
||||
* the shared {@link ProcessingStatusIcon}, which owns its own colors.) */
|
||||
const STATUS_COLOR: Record<ProjectContextItemStatus, string> = {
|
||||
success: "tw-text-success",
|
||||
failed: "tw-text-error",
|
||||
processing: "tw-text-accent",
|
||||
notStarted: "tw-text-muted",
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// ItemCard Component
|
||||
// ============================================================================
|
||||
|
|
@ -257,17 +307,53 @@ interface ItemCardProps {
|
|||
item: GroupItem;
|
||||
viewMode: "list";
|
||||
loadStatus?: ProjectContextItemStatusInfo;
|
||||
/** Agent (Links) variant: per-file conversion status from the agent pipeline,
|
||||
* rendered via the shared {@link ProcessingStatusIcon}. CAG uses `loadStatus`. */
|
||||
agentProcessingItem?: ProcessingItem;
|
||||
onDelete: (e: React.MouseEvent, item: GroupItem) => void;
|
||||
/** Optional: callback to open the cached parsed content for this file. */
|
||||
onOpenCached?: () => void;
|
||||
/** Agent (Links) variant: show status as a bare icon, no text label — matches
|
||||
* the Links panel's icon-only status. CAG keeps icon + label. */
|
||||
compactStatus?: boolean;
|
||||
}
|
||||
|
||||
function ItemCard({ item, viewMode, loadStatus, onDelete, onOpenCached }: ItemCardProps) {
|
||||
function ItemCard({
|
||||
item,
|
||||
viewMode,
|
||||
loadStatus,
|
||||
agentProcessingItem,
|
||||
onDelete,
|
||||
onOpenCached,
|
||||
compactStatus,
|
||||
}: ItemCardProps) {
|
||||
const extension = item.id.split(".").pop() || "";
|
||||
|
||||
// add or remove
|
||||
const IconComponent = item.isIgnored ? Plus : XIcon;
|
||||
|
||||
// Shared "view parsed content" arrow (revealed on row hover for converted items).
|
||||
// "Converted" is the agent item's `ready` state (Links variant) or the CAG
|
||||
// `success` state — each surface feeds the matching status above.
|
||||
const hasConvertedSnapshot = compactStatus
|
||||
? agentProcessingItem?.status === "ready"
|
||||
: loadStatus?.status === "success";
|
||||
const previewButton =
|
||||
onOpenCached && hasConvertedSnapshot ? (
|
||||
<Button
|
||||
variant="ghost2"
|
||||
size="icon"
|
||||
className="tw-hidden tw-size-5 group-hover:tw-block"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenCached();
|
||||
}}
|
||||
title="View Parsed Content"
|
||||
>
|
||||
<ArrowUpRight className="tw-size-4" />
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="tw-group tw-flex tw-cursor-pointer tw-items-center tw-rounded-lg tw-border tw-border-solid tw-border-border tw-p-2 tw-transition-shadow hover:tw-shadow-md">
|
||||
<div className="tw-mr-2 tw-shrink-0">
|
||||
|
|
@ -284,47 +370,45 @@ function ItemCard({ item, viewMode, loadStatus, onDelete, onOpenCached }: ItemCa
|
|||
</div>
|
||||
|
||||
<div className="tw-ml-auto tw-flex tw-min-w-[24px] tw-items-center tw-justify-end tw-gap-2">
|
||||
{loadStatus && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"tw-flex tw-items-center tw-gap-1 tw-whitespace-nowrap",
|
||||
loadStatus.status === "success" && "tw-text-success",
|
||||
loadStatus.status === "failed" && "tw-text-error",
|
||||
loadStatus.status === "processing" && "tw-text-accent",
|
||||
loadStatus.status === "notStarted" && "tw-text-muted"
|
||||
{compactStatus ? (
|
||||
// Agent (Links-style): order is [preview][status][delete]; the status is a
|
||||
// bare icon (ready hidden until row hover), error revealed on hover.
|
||||
<>
|
||||
{previewButton}
|
||||
{agentProcessingItem && (
|
||||
<ProcessingStatusIcon item={agentProcessingItem} revealReadyOnHover />
|
||||
)}
|
||||
title={
|
||||
loadStatus.status === "failed" && loadStatus.failedItem?.error
|
||||
? `Failed: ${loadStatus.failedItem.error}`
|
||||
: STATUS_LABELS[loadStatus.status]
|
||||
}
|
||||
>
|
||||
{loadStatus.status === "processing" ? (
|
||||
<Loader2 className="tw-size-3 tw-animate-spin" />
|
||||
) : loadStatus.status === "success" ? (
|
||||
<CheckCircle className="tw-size-3" />
|
||||
) : loadStatus.status === "failed" ? (
|
||||
<AlertCircle className="tw-size-3" />
|
||||
) : (
|
||||
<div className="tw-size-2 tw-rounded-full tw-border tw-border-solid tw-border-border" />
|
||||
</>
|
||||
) : (
|
||||
// CAG (unchanged): icon + text Badge, then the preview arrow.
|
||||
<>
|
||||
{loadStatus && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"tw-flex tw-items-center tw-gap-1 tw-whitespace-nowrap",
|
||||
STATUS_COLOR[loadStatus.status]
|
||||
)}
|
||||
title={
|
||||
loadStatus.status === "failed" && loadStatus.failedItem?.error
|
||||
? `Failed: ${loadStatus.failedItem.error}`
|
||||
: STATUS_LABELS[loadStatus.status]
|
||||
}
|
||||
>
|
||||
{loadStatus.status === "processing" ? (
|
||||
<Loader2 className="tw-size-3 tw-animate-spin" />
|
||||
) : loadStatus.status === "success" ? (
|
||||
<CheckCircle className="tw-size-3" />
|
||||
) : loadStatus.status === "failed" ? (
|
||||
<AlertCircle className="tw-size-3" />
|
||||
) : (
|
||||
<div className="tw-size-2 tw-rounded-full tw-border tw-border-solid tw-border-border" />
|
||||
)}
|
||||
<span className="tw-hidden md:tw-inline">{STATUS_LABELS[loadStatus.status]}</span>
|
||||
</Badge>
|
||||
)}
|
||||
<span className="tw-hidden md:tw-inline">{STATUS_LABELS[loadStatus.status]}</span>
|
||||
</Badge>
|
||||
)}
|
||||
{onOpenCached && loadStatus?.status === "success" && (
|
||||
<Button
|
||||
variant="ghost2"
|
||||
size="icon"
|
||||
className="tw-hidden tw-size-5 group-hover:tw-block"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenCached();
|
||||
}}
|
||||
title="View Parsed Content"
|
||||
>
|
||||
<ArrowUpRight className="tw-size-4" />
|
||||
</Button>
|
||||
{previewButton}
|
||||
</>
|
||||
)}
|
||||
<IconComponent
|
||||
className="tw-hidden tw-size-4 tw-shrink-0 tw-text-muted hover:tw-text-warning group-hover:tw-block group-hover:tw-flex-none"
|
||||
|
|
@ -370,7 +454,13 @@ function CategoryItemCard({
|
|||
onClick={() => onClick(item)}
|
||||
>
|
||||
<div className="tw-mr-2 tw-shrink-0">
|
||||
<IconComponent className={`tw-size-6 ${iconColorClassName}`} />
|
||||
{item.type === "web" || item.type === "youtube" ? (
|
||||
// Reuse the canonical URL glyph so the card matches every other URL
|
||||
// surface (Links sidebar, +URL popover, context chips).
|
||||
<UrlTypeIcon type={item.type} className="tw-size-6" />
|
||||
) : (
|
||||
IconComponent && <IconComponent className={`tw-size-6 ${iconColorClassName}`} />
|
||||
)}
|
||||
</div>
|
||||
<div className="tw-flex tw-min-w-0 tw-flex-1 tw-flex-col">
|
||||
<TruncatedText className="tw-flex-1 tw-text-sm tw-font-medium">
|
||||
|
|
@ -390,6 +480,12 @@ interface ContextManageProps {
|
|||
onSave: (project: ProjectConfig) => void;
|
||||
onCancel: () => void;
|
||||
app: App;
|
||||
/** Agent Mode: show the Links (Web/YouTube) section and persist URL edits.
|
||||
* Off for CAG callers, leaving this modal's file-only behavior unchanged. */
|
||||
enableLinks?: boolean;
|
||||
/** Portal target for the Links +URL popover — the modal's own `contentEl`, so
|
||||
* the popover (layer 30) stacks above this modal (layer 50). */
|
||||
popoverContainer?: HTMLElement | null;
|
||||
}
|
||||
|
||||
interface GroupItem {
|
||||
|
|
@ -412,7 +508,7 @@ interface IgnoreItems {
|
|||
interface CategoryItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "tag" | "folder" | "files" | "ignoreFiles";
|
||||
type: "tag" | "folder" | "files" | "ignoreFiles" | "web" | "youtube";
|
||||
originalId?: string;
|
||||
count: number;
|
||||
}
|
||||
|
|
@ -423,13 +519,25 @@ function isCategoryItem(item: DisplayItem): item is CategoryItem {
|
|||
return "type" in item;
|
||||
}
|
||||
|
||||
function ContextManage({ initialProject, onSave, onCancel, app }: ContextManageProps) {
|
||||
function ContextManage({
|
||||
initialProject,
|
||||
onSave,
|
||||
onCancel,
|
||||
app,
|
||||
enableLinks = false,
|
||||
popoverContainer,
|
||||
}: ContextManageProps) {
|
||||
const isMobile = Platform.isMobile;
|
||||
const [contextLoadState] = useProjectContextLoad();
|
||||
const contextUrls = useContextUrls(initialProject);
|
||||
const [projectCache, setProjectCache] = useState<ContextCache | null>(null);
|
||||
|
||||
// Load project cache on mount
|
||||
// Load project cache on mount. Skipped for the Agent (Links) variant: the
|
||||
// agent pipeline never writes the CAG ProjectContextCache, and its file rows
|
||||
// no longer consume `projectCache` (see the ItemCard loadStatus/onOpenCached
|
||||
// gating), so the read would only burn a disk hit and a re-render.
|
||||
useEffect(() => {
|
||||
if (enableLinks) return;
|
||||
let isMounted = true;
|
||||
const loadCache = async () => {
|
||||
const cache = await ProjectContextCache.getInstance().get(initialProject);
|
||||
|
|
@ -441,7 +549,7 @@ function ContextManage({ initialProject, onSave, onCancel, app }: ContextManageP
|
|||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [initialProject]);
|
||||
}, [initialProject, enableLinks]);
|
||||
|
||||
// Check if viewing the currently loaded project
|
||||
const isCurrentProject = useMemo(() => {
|
||||
|
|
@ -481,6 +589,23 @@ function ContextManage({ initialProject, onSave, onCancel, app }: ContextManageP
|
|||
isCurrentProject,
|
||||
]);
|
||||
|
||||
// Agent (Links) variant ONLY: one shared conversion-status lookup keyed by
|
||||
// `processingSourceKey`, covering both URL rows and File Context rows so they
|
||||
// render the same {@link ProcessingStatusIcon}. Gated to `enableLinks` so the
|
||||
// CAG path skips the off-vault/fs read and gets a stable empty result (the
|
||||
// hook still subscribes to the agent atom, which is harmless — its value is
|
||||
// unused when disabled).
|
||||
const { items: agentProcessingItems } = useAgentProcessingItems(
|
||||
app,
|
||||
initialProject,
|
||||
initialProject.contextSource,
|
||||
{ enabled: enableLinks }
|
||||
);
|
||||
const agentProcessingByKey = useMemo(
|
||||
() => buildProcessingItemLookup(agentProcessingItems),
|
||||
[agentProcessingItems]
|
||||
);
|
||||
|
||||
const { inclusions: inclusionPatterns, exclusions: exclusionPatterns } = useMemo(() => {
|
||||
return getMatchingPatterns({
|
||||
inclusions: initialProject?.contextSource.inclusions,
|
||||
|
|
@ -607,6 +732,41 @@ function ContextManage({ initialProject, onSave, onCancel, app }: ContextManageP
|
|||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [activeSection, setActiveSection] = useState<ActiveSection>(null);
|
||||
const [activeItem, setActiveItem] = useState<ActiveItem>(null);
|
||||
const isLinksActive =
|
||||
activeSection === "links" || activeSection === "web" || activeSection === "youtube";
|
||||
|
||||
// A file row's status + snapshot-preview, resolved in ONE place so the JSX
|
||||
// doesn't branch on `enableLinks` per prop. Agent reads the shared agent
|
||||
// pipeline (status icon + off-vault snapshot); CAG reads its ProjectContextCache
|
||||
// (badge + in-vault parsed content). Ignored rows get neither.
|
||||
const getFileRowStatusProps = useCallback(
|
||||
(
|
||||
item: GroupItem
|
||||
): Pick<ItemCardProps, "agentProcessingItem" | "loadStatus" | "onOpenCached"> => {
|
||||
if (item.isIgnored || activeSection === "ignoreFiles") return {};
|
||||
if (enableLinks) {
|
||||
const agentItem = agentProcessingByKey.get(processingSourceKey("file", item.id));
|
||||
return {
|
||||
agentProcessingItem: agentItem,
|
||||
onOpenCached: agentItem
|
||||
? () => void openAgentCachedItemPreview(app, agentItem)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
const isMarkdown = item.id.split(".").pop()?.toLowerCase() === "md";
|
||||
return {
|
||||
loadStatus: getProjectContextItemStatus(item.id, contextLoadLookup),
|
||||
// Markdown isn't converted, so it has no parsed snapshot to open.
|
||||
onOpenCached: isMarkdown
|
||||
? undefined
|
||||
: () => {
|
||||
const name = item.name || item.id.split("/").pop() || item.id;
|
||||
void openCachedProjectFile(app, projectCache, item.id, name);
|
||||
},
|
||||
};
|
||||
},
|
||||
[enableLinks, activeSection, agentProcessingByKey, contextLoadLookup, app, projectCache]
|
||||
);
|
||||
|
||||
// groupList convert to inclusions format
|
||||
const convertGroupListToInclusions = useCallback(
|
||||
|
|
@ -768,6 +928,20 @@ function ContextManage({ initialProject, onSave, onCancel, app }: ContextManageP
|
|||
return [];
|
||||
}
|
||||
|
||||
// Clicking the Tags header (agent Links variant) lists every tag. CAG never
|
||||
// reaches this state — its header isn't clickable — so behavior is unchanged.
|
||||
if (activeSection === "tags") {
|
||||
return sortItems(
|
||||
Object.entries(groupList.tags).map(([tagId, files]) => ({
|
||||
id: `tag:${tagId}`,
|
||||
name: tagId.slice(1),
|
||||
type: "tag",
|
||||
originalId: tagId,
|
||||
count: files.length,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (activeSection === "folders" && activeItem) {
|
||||
const folderFiles = groupList.folders[activeItem];
|
||||
if (folderFiles) {
|
||||
|
|
@ -776,6 +950,19 @@ function ContextManage({ initialProject, onSave, onCancel, app }: ContextManageP
|
|||
return [];
|
||||
}
|
||||
|
||||
// Clicking the Folders header (agent Links variant) lists every folder.
|
||||
if (activeSection === "folders") {
|
||||
return sortItems(
|
||||
Object.entries(groupList.folders).map(([folderId, files]) => ({
|
||||
id: `folder:${folderId}`,
|
||||
name: folderId,
|
||||
type: "folder",
|
||||
originalId: folderId,
|
||||
count: files.length,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (activeSection === "files") {
|
||||
return groupList.notes;
|
||||
}
|
||||
|
|
@ -841,7 +1028,24 @@ function ContextManage({ initialProject, onSave, onCancel, app }: ContextManageP
|
|||
]
|
||||
: [];
|
||||
|
||||
return [...tagItems, ...folderItems, ...filesItem, ...ignoreFilesItem];
|
||||
// Agent (Links) variant only: list Web and YouTube as their own cards so
|
||||
// the overview surfaces every context type the same way (one card per
|
||||
// non-empty group, exactly like folders). Leads the grid to mirror the
|
||||
// sidebar, where Links sits first.
|
||||
const webCount = enableLinks
|
||||
? contextUrls.urlItems.filter((u) => u.type === "web").length
|
||||
: 0;
|
||||
const youtubeCount = enableLinks
|
||||
? contextUrls.urlItems.filter((u) => u.type === "youtube").length
|
||||
: 0;
|
||||
const linkItems = [
|
||||
...(webCount > 0 ? [{ id: "web:all", name: "Web", type: "web", count: webCount }] : []),
|
||||
...(youtubeCount > 0
|
||||
? [{ id: "youtube:all", name: "YouTube", type: "youtube", count: youtubeCount }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return [...linkItems, ...tagItems, ...folderItems, ...filesItem, ...ignoreFilesItem];
|
||||
}
|
||||
|
||||
return [];
|
||||
|
|
@ -858,6 +1062,8 @@ function ContextManage({ initialProject, onSave, onCancel, app }: ContextManageP
|
|||
groupList.extensions,
|
||||
ignoreItems.files,
|
||||
sortItems,
|
||||
enableLinks,
|
||||
contextUrls.urlItems,
|
||||
]);
|
||||
|
||||
const makeSectionItem = useCallback(
|
||||
|
|
@ -1085,19 +1291,26 @@ function ContextManage({ initialProject, onSave, onCancel, app }: ContextManageP
|
|||
groupHandlers.click.files();
|
||||
} else if (item.type === "ignoreFiles") {
|
||||
groupHandlers.click.ignoreFiles();
|
||||
} else if (item.type === "web" || item.type === "youtube") {
|
||||
setActiveState(item.type);
|
||||
}
|
||||
},
|
||||
[groupHandlers]
|
||||
[groupHandlers, setActiveState]
|
||||
);
|
||||
|
||||
const getDisplayTitle = () => {
|
||||
if (searchTerm) return `Search Results for: "${searchTerm}"`;
|
||||
if (activeSection === "links") return "Links";
|
||||
if (activeSection === "web") return "Web";
|
||||
if (activeSection === "youtube") return "YouTube";
|
||||
if (activeSection === "tags" && activeItem) {
|
||||
return `Tag: ${activeItem}`;
|
||||
}
|
||||
if (activeSection === "tags") return "Tags";
|
||||
if (activeSection === "folders" && activeItem) {
|
||||
return `Folder: ${activeItem}`;
|
||||
}
|
||||
if (activeSection === "folders") return "Folders";
|
||||
if (activeSection === "files") return "Files";
|
||||
if (activeSection === "extensions" && activeItem) {
|
||||
return `Extension: ${activeItem}`;
|
||||
|
|
@ -1106,6 +1319,12 @@ function ContextManage({ initialProject, onSave, onCancel, app }: ContextManageP
|
|||
return "All Categories";
|
||||
};
|
||||
|
||||
// Agent Links variant: clicking the Tags/Folders header lists that category's
|
||||
// entries on the right. Those are CategoryItems, so the right pane must use the
|
||||
// category-card branch (not the file ItemCard branch) for these states.
|
||||
const showingCategoryItems =
|
||||
!searchTerm && !activeItem && (activeSection === "tags" || activeSection === "folders");
|
||||
|
||||
const handleDeleteItem = (e: React.MouseEvent, item: GroupItem) => {
|
||||
e.stopPropagation();
|
||||
|
||||
|
|
@ -1167,6 +1386,11 @@ function ContextManage({ initialProject, onSave, onCancel, app }: ContextManageP
|
|||
...initialProject.contextSource,
|
||||
inclusions: include,
|
||||
exclusions: exclude,
|
||||
// Agent Mode only: persist URL edits back. CAG callers don't enable
|
||||
// Links, so their save payload is byte-for-byte unchanged.
|
||||
...(enableLinks
|
||||
? { webUrls: contextUrls.webUrls, youtubeUrls: contextUrls.youtubeUrls }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -1184,6 +1408,25 @@ function ContextManage({ initialProject, onSave, onCancel, app }: ContextManageP
|
|||
|
||||
<ScrollArea className="tw-max-h-[500px] tw-flex-1">
|
||||
<div className="tw-space-y-6 tw-p-4">
|
||||
{/* Links first: URLs are the most-used source in agent projects,
|
||||
so the section leads the navigation when links are enabled. */}
|
||||
{enableLinks && (
|
||||
<>
|
||||
<LinksSidebarSection
|
||||
activeSection={activeSection}
|
||||
webCount={contextUrls.urlItems.filter((u) => u.type === "web").length}
|
||||
youtubeCount={contextUrls.urlItems.filter((u) => u.type === "youtube").length}
|
||||
onSelect={(s) => setActiveState(s)}
|
||||
existingUrls={contextUrls.urlItems.map((u) => u.url)}
|
||||
onAddUrls={(items) =>
|
||||
contextUrls.addFromText(items.map((i) => i.url).join("\n"))
|
||||
}
|
||||
popoverContainer={popoverContainer}
|
||||
/>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tags Section */}
|
||||
<SectionList
|
||||
title="Tags"
|
||||
|
|
@ -1198,6 +1441,7 @@ function ContextManage({ initialProject, onSave, onCancel, app }: ContextManageP
|
|||
onAddClick={groupHandlers.add.tag}
|
||||
onDeleteItem={(e, item) => groupHandlers.delete.tag(e, item)}
|
||||
tooltip="must be in note property"
|
||||
onSectionClick={enableLinks ? () => setActiveState("tags", null) : undefined}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
|
@ -1214,6 +1458,7 @@ function ContextManage({ initialProject, onSave, onCancel, app }: ContextManageP
|
|||
onItemClick={groupHandlers.click.folder}
|
||||
onAddClick={groupHandlers.add.folder}
|
||||
onDeleteItem={(e, item) => groupHandlers.delete.folder(e, item)}
|
||||
onSectionClick={enableLinks ? () => setActiveState("folders", null) : undefined}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
|
@ -1306,7 +1551,15 @@ function ContextManage({ initialProject, onSave, onCancel, app }: ContextManageP
|
|||
|
||||
{/* Content Area */}
|
||||
<ScrollArea className="tw-max-h-[400px] tw-flex-1 tw-p-4 tw-pt-0">
|
||||
{getDisplayItems.length === 0 ? (
|
||||
{isLinksActive ? (
|
||||
<LinksContentPanel
|
||||
app={app}
|
||||
urlItems={contextUrls.urlItems}
|
||||
filter={activeSection}
|
||||
agentProcessingByKey={agentProcessingByKey}
|
||||
onRemove={contextUrls.removeUrl}
|
||||
/>
|
||||
) : getDisplayItems.length === 0 ? (
|
||||
<div className="tw-mt-10 tw-text-center tw-text-muted">
|
||||
{activeSection
|
||||
? "No items found."
|
||||
|
|
@ -1317,34 +1570,32 @@ function ContextManage({ initialProject, onSave, onCancel, app }: ContextManageP
|
|||
{activeSection || searchTerm
|
||||
? // When a category is selected or a search is performed, display the normal item list.
|
||||
sortItems(getDisplayItems)
|
||||
.map((item) =>
|
||||
!isCategoryItem(item) ? (
|
||||
.map((item) => {
|
||||
if (showingCategoryItems && isCategoryItem(item)) {
|
||||
return (
|
||||
<CategoryItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={handleCategoryItemClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isCategoryItem(item)) return null;
|
||||
return (
|
||||
<ItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
viewMode="list"
|
||||
loadStatus={
|
||||
activeSection === "ignoreFiles" || item.isIgnored
|
||||
? undefined
|
||||
: getProjectContextItemStatus(item.id, contextLoadLookup)
|
||||
}
|
||||
compactStatus={enableLinks}
|
||||
onDelete={
|
||||
activeSection === "ignoreFiles" || item.isIgnored
|
||||
? handleDeleteIgnoreItem
|
||||
: handleDeleteItem
|
||||
}
|
||||
onOpenCached={
|
||||
// Reason: only offer open for non-markdown files that have been processed
|
||||
!item.isIgnored && item.id.split(".").pop()?.toLowerCase() !== "md"
|
||||
? () => {
|
||||
const name = item.name || item.id.split("/").pop() || item.id;
|
||||
void openCachedProjectFile(app, projectCache, item.id, name);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
{...getFileRowStatusProps(item)}
|
||||
/>
|
||||
) : null
|
||||
)
|
||||
);
|
||||
})
|
||||
.filter(Boolean)
|
||||
: // When no category is selected and no search, display the grouped category list.
|
||||
getDisplayItems
|
||||
|
|
@ -1380,7 +1631,8 @@ export class ContextManageModal extends Modal {
|
|||
constructor(
|
||||
app: App,
|
||||
private onSave: (project: ProjectConfig) => void,
|
||||
private initialProject: ProjectConfig
|
||||
private initialProject: ProjectConfig,
|
||||
private options: { enableLinks?: boolean } = {}
|
||||
) {
|
||||
super(app);
|
||||
}
|
||||
|
|
@ -1406,6 +1658,8 @@ export class ContextManageModal extends Modal {
|
|||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
app={this.app}
|
||||
enableLinks={this.options.enableLinks}
|
||||
popoverContainer={contentEl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
57
src/components/modals/project/useContextUrls.ts
Normal file
57
src/components/modals/project/useContextUrls.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { ProjectConfig } from "@/aiParams";
|
||||
import {
|
||||
parseProjectUrls,
|
||||
parseUrlsFromText,
|
||||
serializeProjectUrls,
|
||||
type UrlItem,
|
||||
} from "@/utils/urlTagUtils";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
export interface ContextUrlsState {
|
||||
webUrls: string;
|
||||
youtubeUrls: string;
|
||||
urlItems: UrlItem[];
|
||||
/** Parse free text (single / batch paste), classify, dedup, and append. */
|
||||
addFromText: (text: string) => void;
|
||||
removeUrl: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draft URL state for the Manage modal's Links panel: keeps the two
|
||||
* newline-separated ProjectConfig strings, exposes them as parsed
|
||||
* {@link UrlItem}s, and edits via the shared {@link parseUrlsFromText} /
|
||||
* {@link serializeProjectUrls} so add/dedup/classify match UrlTagInput and the
|
||||
* +URL modal exactly. Committed back into the project on the modal's Save.
|
||||
*/
|
||||
export function useContextUrls(initial: ProjectConfig): ContextUrlsState {
|
||||
const [webUrls, setWebUrls] = useState(initial.contextSource?.webUrls ?? "");
|
||||
const [youtubeUrls, setYoutubeUrls] = useState(initial.contextSource?.youtubeUrls ?? "");
|
||||
|
||||
const urlItems = useMemo(() => parseProjectUrls(webUrls, youtubeUrls), [webUrls, youtubeUrls]);
|
||||
|
||||
const commit = useCallback((items: UrlItem[]) => {
|
||||
const serialized = serializeProjectUrls(items);
|
||||
setWebUrls(serialized.webUrls);
|
||||
setYoutubeUrls(serialized.youtubeUrls);
|
||||
}, []);
|
||||
|
||||
const addFromText = useCallback(
|
||||
(text: string) => {
|
||||
const added = parseUrlsFromText(
|
||||
text,
|
||||
urlItems.map((u) => u.url)
|
||||
);
|
||||
if (added.length > 0) commit([...urlItems, ...added]);
|
||||
},
|
||||
[urlItems, commit]
|
||||
);
|
||||
|
||||
const removeUrl = useCallback(
|
||||
(id: string) => {
|
||||
commit(urlItems.filter((u) => u.id !== id));
|
||||
},
|
||||
[urlItems, commit]
|
||||
);
|
||||
|
||||
return { webUrls, youtubeUrls, urlItems, addFromText, removeUrl };
|
||||
}
|
||||
193
src/components/project/AddUrlPopover.tsx
Normal file
193
src/components/project/AddUrlPopover.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { UrlInputRow } from "@/components/project/UrlInputRow";
|
||||
import { UrlTypeIcon } from "@/components/project/UrlTypeIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createUrlItem, normalizeUrl, resolveInputUrls, type UrlItem } from "@/utils/urlTagUtils";
|
||||
import { Link, Plus, X } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
interface AddUrlPopoverProps {
|
||||
/** Existing URLs (raw or normalized) so already-added entries flag as duplicates. */
|
||||
existingUrls: string[];
|
||||
/** The deduped, non-duplicate URLs the user confirmed. The caller merges them. */
|
||||
onAdd: (urls: UrlItem[]) => void;
|
||||
/** Portal target. Pass the host modal's `contentEl` so the popover stacks
|
||||
* above it: the popover layer (30) sits below the modal layer (50), so a
|
||||
* body-portaled popover would render behind the Edit-project modal. */
|
||||
container?: HTMLElement | null;
|
||||
/** Custom trigger (e.g. the Manage sidebar's PlusCircle). Defaults to the
|
||||
* footer's cyan "+ URL" link. */
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
/** A URL staged for adding, tagged with whether it already lives in the project's
|
||||
* context (a duplicate is shown but not committed). */
|
||||
interface PendingUrl {
|
||||
item: UrlItem;
|
||||
duplicate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "+ URL" control (design ⑤): a trigger opening a small popover that stages
|
||||
* URLs in a **pending list** before committing. The input's right-side button is
|
||||
* one control with two states — empty → paste from clipboard, non-empty → add
|
||||
* the typed value (Enter does the same) — and a paste anywhere routes straight
|
||||
* into the list. Each staged URL is auto-classified web/YouTube and deduped:
|
||||
* within the list it collapses, against the project's existing URLs it flags as
|
||||
* "Exists" and is excluded from the commit. "Add N" sends the valid items out
|
||||
* through `onAdd`; the caller merges them into its context source.
|
||||
*
|
||||
* A Popover, NOT a Modal, so it never stacks a second modal over the Edit-project
|
||||
* modal (see the agent layer rules' modal/dialog note). Parsing reuses the shared
|
||||
* {@link resolveInputUrls} (scheme-anchored extraction + single-token fallback),
|
||||
* so a pasted document can't smuggle bare-host prose tokens into the list.
|
||||
*/
|
||||
export function AddUrlPopover({ existingUrls, onAdd, container, trigger }: AddUrlPopoverProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pending, setPending] = useState<PendingUrl[]>([]);
|
||||
|
||||
const existingSet = useMemo(() => new Set(existingUrls.map(normalizeUrl)), [existingUrls]);
|
||||
|
||||
const validCount = pending.reduce((n, p) => (p.duplicate ? n : n + 1), 0);
|
||||
const dupCount = pending.length - validCount;
|
||||
|
||||
// Stage every URL parsed from `text`, skipping ones already staged and tagging
|
||||
// ones already in the project. The single source of truth for all three input
|
||||
// paths (Add button, Enter, paste).
|
||||
const stageFromText = (text: string) => {
|
||||
const parsed = resolveInputUrls(text);
|
||||
if (parsed.length === 0) return;
|
||||
setPending((prev) => {
|
||||
const staged = new Set(prev.map((p) => p.item.url));
|
||||
const additions: PendingUrl[] = [];
|
||||
for (const raw of parsed) {
|
||||
const item = createUrlItem(raw);
|
||||
if (staged.has(item.url)) continue;
|
||||
staged.add(item.url);
|
||||
additions.push({ item, duplicate: existingSet.has(item.url) });
|
||||
}
|
||||
return additions.length > 0 ? [...prev, ...additions] : prev;
|
||||
});
|
||||
};
|
||||
|
||||
const resetAndClose = () => {
|
||||
setPending([]);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
const valid = pending.filter((p) => !p.duplicate).map((p) => p.item);
|
||||
if (valid.length === 0) return;
|
||||
onAdd(valid);
|
||||
resetAndClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) setPending([]);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button
|
||||
variant="ghost2"
|
||||
size="sm"
|
||||
className="tw-h-auto tw-gap-1 tw-px-0 tw-text-context-manager-cyan hover:tw-text-context-manager-cyan"
|
||||
>
|
||||
<Plus className="tw-size-3.5" />
|
||||
URL
|
||||
</Button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" container={container} className="tw-w-80 tw-p-0">
|
||||
<div className="tw-flex tw-items-center tw-gap-2 tw-border-x-[0px] tw-border-b tw-border-t-[0px] tw-border-solid tw-border-border tw-px-3.5 tw-pb-2.5 tw-pt-3 tw-text-sm tw-font-semibold tw-text-normal">
|
||||
<Link className="tw-size-3.5" />
|
||||
Add URLs
|
||||
<Button
|
||||
variant="ghost2"
|
||||
size="icon"
|
||||
aria-label="Close"
|
||||
onClick={resetAndClose}
|
||||
className="tw-ml-auto"
|
||||
>
|
||||
<X className="tw-size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="tw-flex tw-flex-col tw-gap-2 tw-px-3.5 tw-py-3">
|
||||
<UrlInputRow autoFocus onSubmit={stageFromText} placeholder="Enter a URL…" />
|
||||
|
||||
{/* Fixed height (not max-height): the popover's total height stays
|
||||
constant as items are staged, so Radix never re-flips it to dodge a
|
||||
growing box — which read as a jarring jump when opened from the
|
||||
Manage sidebar's tight top corner. */}
|
||||
<div className="tw-flex tw-h-28 tw-flex-col tw-gap-1.5 tw-overflow-y-auto">
|
||||
{pending.length === 0 ? (
|
||||
<div className="tw-flex tw-flex-1 tw-flex-col tw-items-center tw-justify-center tw-gap-1 tw-text-center tw-text-xs tw-text-faint">
|
||||
<Link className="tw-mb-1 tw-size-5" />
|
||||
No URLs queued yet — type or paste above
|
||||
</div>
|
||||
) : (
|
||||
pending.map(({ item, duplicate }) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"tw-flex tw-items-center tw-gap-2 tw-rounded-lg tw-border tw-border-solid tw-px-2.5 tw-py-1.5 tw-text-sm",
|
||||
duplicate ? "tw-border-error tw-bg-error/10" : "tw-border-border"
|
||||
)}
|
||||
>
|
||||
<UrlTypeIcon type={item.type} className="tw-size-3.5 tw-shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
"tw-min-w-0 tw-flex-1 tw-truncate",
|
||||
duplicate ? "tw-text-error" : "tw-text-normal"
|
||||
)}
|
||||
title={item.url}
|
||||
>
|
||||
{item.url.replace(/^https?:\/\//, "")}
|
||||
</span>
|
||||
{/* The leading icon already conveys web/YouTube, so only the
|
||||
duplicate case needs a label — "Exists" explains why it's
|
||||
excluded from the commit. */}
|
||||
{duplicate && (
|
||||
<span className="tw-shrink-0 tw-rounded tw-border tw-border-solid tw-border-error tw-px-1 tw-font-mono tw-text-ui-smaller tw-text-error">
|
||||
Exists
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost2"
|
||||
size="icon"
|
||||
aria-label="Remove"
|
||||
className="tw-shrink-0"
|
||||
onClick={() => setPending((prev) => prev.filter((p) => p.item.id !== item.id))}
|
||||
>
|
||||
<X className="tw-size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tw-flex tw-items-center tw-gap-2 tw-border-x-[0px] tw-border-b-[0px] tw-border-t tw-border-solid tw-border-border tw-px-3.5 tw-py-2.5">
|
||||
<span className="tw-text-xs tw-text-faint">
|
||||
{validCount} to add
|
||||
{dupCount > 0 && ` · ${dupCount} exist`}
|
||||
</span>
|
||||
<div className="tw-ml-auto tw-flex tw-gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={resetAndClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" disabled={validCount === 0} onClick={commit}>
|
||||
Add{validCount > 0 ? ` ${validCount}` : ""}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue