diff --git a/README.md b/README.md index 3c14934..fd35e84 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,9 @@ the resolved app appearance automatically. one grouped control (expand a row for bounded command and output). Model pickers follow the provider CLI, including Claude Fable 5.1 and GPT-6 Astra when advertised. Its compact thread rail and bottom command deck keep chat - primary, with **Open review** routing to Strand's Review surface. Declarative + primary, with **Open review** routing to Strand's Review surface. **Agent + Session Recap** summarizes files touched, risky paths, and leftover TODOs + in the active worktree and can sit next to Review. Declarative plugins render from validated manifests; third-party JavaScript does not execute in the privileged webview. The bundled **Quick Notes** plugin provides an editable scratchpad for each diff --git a/ROADMAP.md b/ROADMAP.md index 0b264cf..dad7e16 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2200,7 +2200,8 @@ the audit does not mark these fixes or proposals shipped. registries, shared hosts, and resilient layout-v2 references shipped 2026-08-28; declarative manifest validation, permission broker, bundled marketplace, Heroi dogfood plugin, and `docs/plugin-creation.md` shipped - 2026-08-29. Typed services/resource leases, quotas, remote install, and + 2026-08-29; Agent Session Recap and `docs/plugin-marketplace-backlog.md` + shipped 2026-10-01. Typed services/resource leases, quotas, remote install, and isolated community execution remain open. - AI features (writing suggestions, conflict hints) — PRD Q3 - ☑ Commit message suggestions from staged diffs, or all unstaged changes @@ -2723,6 +2724,12 @@ sample is now an editable repository-scoped Workbench plugin. Notes are saved under a repository-path key in Strand's app-config `strand.db`, never in the working tree. The placeholder Repo Status marketplace plugin was removed. +**Agent Session Recap shipped (2026-10-01):** bundled builtin +`daniels.session-recap` summarizes files touched, risky paths, and leftover +TODOs in the active repository or worktree. It reuses Review's Open-review +bridge and degrades to a clear empty state with no repo or no changes. Design +note: `docs/plugin-marketplace-backlog.md`. Remote marketplace stays deferred. + **Heroi repository agent chat shipped (2026-08-30):** Heroi is now a focused Workbench chat surface: it filters persisted conversations to the active repository and leaves Files, git changes, diffs, and other tools to composable diff --git a/TASKS.md b/TASKS.md index a17e635..bcd5343 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1474,13 +1474,18 @@ community plugins, performance and platform certification from Git feature gaps. permission broker, quotas, diagnostics, and isolated execution boundary; do not load third-party code into Strand's privileged webview. (manifest validation, capability broker, bundled marketplace, Heroi - builtin plugin, `docs/plugin-creation.md` — 2026-08-29; quotas, + builtin plugin, `docs/plugin-creation.md` — 2026-08-29; Agent Session Recap + and `docs/plugin-marketplace-backlog.md` — 2026-10-01; quotas, remote install, and isolated custom UI remain open.) - ☑ Bundled plugin marketplace in Settings → Plugins with user-level install persistence (`plugins.installed`, `ui/src/plugins/marketplace.ts`). - ☑ Repository-scoped Quick Notes plugin with debounced persistence in Strand's app database; removed the Repo Status sample plugin (`QuickNotesView`, `quick-notes:` — 2026-08-30). +- ☑ Agent Session Recap plugin (`daniels.session-recap`, `AgentSessionRecapView`, + `buildAgentSessionRecap`) — bundled builtin summarizing files touched, risky + paths, and TODOs in the active worktree next to Review; empty/unavailable + without repo or changes (`docs/plugin-marketplace-backlog.md` — DAN-77). - ☑ Heroi Workbench surface (`daniels.heroi.workspace`, `HeroiView`, `heroi_agent_send`, `heroi_provider_models` — active-repository-only chat with streaming, resumable, cancellable Claude/Codex/Cursor Agent sessions; @@ -1500,6 +1505,10 @@ community plugins, performance and platform certification from Git feature gaps. Claude Fable 5.1 (`claude-fable-5-1`) plus GPT-6 Astra (`gpt-6-astra`) (`heroi.rs`, `heroi/models.rs`, `HeroiView`, `attachments.ts` — DAN-70). - ☑ Plugin-creation guide for AI/manifest authors (`docs/plugin-creation.md`). +- ☐ Risk Radar plugin after a declarative `list`/`badge` view (or tree-row + slot) exists; do not invent those types in Recap (`docs/plugin-marketplace-backlog.md`). +- ☐ Remote plugin marketplace / signed index only after isolation is proven, + in the order recorded in `docs/plugin-marketplace-backlog.md`. - ☐ Run native workspace-scoped Workbench persistence and live-terminal continuity E2E on macOS, Windows, and Linux builds (browser QA covers layout, focus, resizing, module moves, and overflow; native SQLite/PTYS require packaged app passes). diff --git a/docs/README.md b/docs/README.md index c4bdf47..76b5839 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,9 +5,12 @@ Project documentation. - [`git-client-feature-audit-2026-09-06.md`](./git-client-feature-audit-2026-09-06.md) — current code-backed list of 19 missing/partial Git-client feature families, priorities, workarounds, and completion criteria. +- [`plugin-marketplace-backlog.md`](./plugin-marketplace-backlog.md) — Tier 1–4 + plugin ideas vs today's declarative model; remote marketplace is deferred. - [`learnings.md`](./learnings.md) — durable rules learned while building Strand. Read before touching the UI; append when you discover something future work has to respect. +- [`plugin-creation.md`](./plugin-creation.md) — how to author a declarative plugin. - [`pull-request-improvements.md`](./pull-request-improvements.md) — competitive research and the prioritized UX/UI proposal for the hosted PR workspace. - [`release-checklist.md`](./release-checklist.md) — fail-closed 1.0 automated, diff --git a/docs/learnings.md b/docs/learnings.md index 6638d59..023ab74 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -2979,3 +2979,22 @@ membership; cached libgit2 indexes can survive external Git writes. Preserve sparse expansion through its existing reader. git2 0.19 `Index::get_path` already normalizes Windows separators through `path_to_repo_path`; do not add lossy string conversion to fix a raw-libgit2 issue the Rust binding handles. + +## Agent Session Recap is a Strand builtin, not static markdown (2026-10-01) + +**Rule.** Community plugins stay data-only (`markdown` / `status`, +`repository.read` / `ai.invoke`). Live worktree, review-baseline, and loaded +diff context cannot be expressed as static declarative content, so Agent +Session Recap ships as `daniels.session-recap` with `render.kind = "builtin"`, +the same reservation used by Heroi and Quick Notes. It may read the active +repository store and a bounded local/review diff retainer while visible. It +must not add `list`/`badge` view types, enable `network.fetch`, duplicate +Review's diff viewer, or become a background whole-tree patch loader. Empty +and no-repository states are required. Remote marketplace stays deferred +(`docs/plugin-marketplace-backlog.md`). + +**Why.** Claude research mapped Recap onto today's primitives, but the +manifest views are snapshots. Pretending a static markdown surface can follow +an agent worktree would either lie about the API or pollute the privileged +webview with third-party JS. + diff --git a/docs/plugin-creation.md b/docs/plugin-creation.md index 4a359f0..4c8a1b3 100644 --- a/docs/plugin-creation.md +++ b/docs/plugin-creation.md @@ -11,10 +11,10 @@ the trust boundary this format implements. - Namespaced surface contributions merged into the Workbench `SurfaceRegistry` - Permission-checked capability broker (`repository.read`, `ai.invoke`, `network.fetch`) - Declarative surfaces rendered by Strand (`markdown`, `status`) -- One built-in dogfood plugin: **Heroi** (`daniels.heroi`) — Strand-hosted - repository-scoped coding-agent chat with native, streaming Claude, Codex, - and Cursor Agent sessions. Files, diffs, git changes, and other tooling stay - in their own Workbench surfaces. +- Strand-maintained builtins: **Heroi** (`daniels.heroi`) for repository-scoped + coding-agent chat, **Agent Session Recap** (`daniels.session-recap`) for a + files/risky/TODO summary of the active worktree next to Review, and + **Quick Notes** (`example.quick-notes`). Community plugins cannot load arbitrary React, touch Zustand, call Tauri directly, or access the DOM. Those capabilities require future isolated runtimes. @@ -107,9 +107,10 @@ Strand renders both view types with first-party components and theme tokens. ### Built-in renderers (Strand-maintained only) -Only Strand may ship `render.kind = "builtin"`. Today the allowed module is -`daniels.heroi.workspace` for the Heroi dogfood plugin. Third-party manifests -that declare `builtin` are rejected at validation time. +Only Strand may ship `render.kind = "builtin"`. Today the allowed modules are +`daniels.heroi.workspace`, `daniels.session-recap.workspace`, and +`strand-tools.quick-notes.workspace`. Third-party manifests that declare +`builtin` are rejected at validation time. ## Validation checklist @@ -178,6 +179,7 @@ pnpm --filter ./ui test | `ui/src/plugins/marketplace.ts` | Bundled catalog | | `ui/src/plugins/renderSurface.tsx` | Declarative + builtin render routing | | `ui/src/plugins/builtins/heroi/` | Heroi dogfood plugin | +| `ui/src/plugins/builtins/agentSessionRecap/` | Agent Session Recap dogfood plugin | | `ui/src/workbench/SurfaceHost.tsx` | Host lifecycle contract | ## Non-goals (this phase) diff --git a/docs/plugin-marketplace-backlog.md b/docs/plugin-marketplace-backlog.md new file mode 100644 index 0000000..866c3b2 --- /dev/null +++ b/docs/plugin-marketplace-backlog.md @@ -0,0 +1,106 @@ +# Plugin marketplace backlog + +**Status:** Design note for DAN-77. Grounded in `docs/plugin-creation.md` and +`docs/extensibility-architecture.md`. Does not ship new permissions, view +types, or a remote catalog. + +**Today's model.** Community and bundled plugins are data-only. Strand +validates a manifest (`apiVersion: "1"`), registers namespaced Workbench +surfaces, and renders them itself. Third-party JavaScript does not run in the +privileged webview. The capability broker exposes three named permissions: + +| Permission | Ships today | +| --- | --- | +| `repository.read` | Read-only snapshot: path, name, branch, HEAD, dirty flag. | +| `ai.invoke` | Existing provider CLI orchestration (`repoSuggestCommitMessage`). | +| `network.fetch` | Reserved; the broker does not enable it. | + +Declarative view types are `markdown` and `status`. `render.kind = "builtin"` +is reserved for Strand-maintained modules (Heroi, Quick Notes, Agent Session +Recap). Remote downloads, arbitrary React, and direct Tauri invoke remain +non-goals until isolation is proven. + +Static `markdown` / `status` content cannot follow the active worktree, review +baseline, or loaded diffs. Anything that must read that context is a +Strand-hosted builtin that still requests only real permissions and degrades +to an empty state when context is missing. + +## Suggested marketplace order (deferred) + +Do not start a remote catalog in this phase. Architecture already lists +marketplace, signed indexes, and monetization as non-goals before local +permission and isolation hold. When that work is justified, suggested order: + +1. **JSON Schema + "load unpacked"** — author against the existing manifest + schema; install from a local folder for dogfood. +2. **Registry CI** — validate manifests in Strand's repo (what the bundled + catalog already does at install time). +3. **Signed index** — hashed, signed catalog; no unsigned remote execute. +4. **Re-consent / revocation** — changing permissions re-prompts; revoke + without plugin cooperation (broker already fails closed). +5. **Site / deep links** — install URLs after signing and consent exist. +6. **Non-verified publishers** — last, and only behind isolation plus + explicit user consent. + +Until an isolated runtime exists, Settings → Plugins stays a bundled +in-app list (`ui/src/plugins/marketplace.ts`). + +## Tier 1 — fits today's primitives + +Ideas that can ship with `markdown` / `status` and `repository.read` / +`ai.invoke`, or as a Strand-maintained builtin that uses those same +permissions and existing repo/review state. + +| Idea | Today's model | Notes | +| --- | --- | --- | +| **Agent Session Recap** | Builtin (`daniels.session-recap`) | First dogfood for this note. Summarizes files touched, risky paths, and TODOs in loaded patches. `repository.read` only. Sits next to Review; does not grow a diff viewer. | +| Quick Notes | Builtin (`example.quick-notes`) | Already shipped. Scratchpad keyed by repository path. | +| Heroi | Builtin (`daniels.heroi`) | Already shipped. Chat only; Files/Review stay separate panes. | +| Static markdown checklists | Declarative `markdown` | No permissions. Fine for pinned runbooks. | +| Repo snapshot card | Declarative `status` | Branch/HEAD/dirty labels. The old Repo Status sample was removed; do not revive it unless it earns a pane. | +| AI one-liner of uncommitted work | `ai.invoke` | Already first-party via commit-message suggestion. Not a plugin. | + +**Claude's other Tier-1 pick, Risk Radar,** is not shippable on today's +views. A useful radar wants a `list` / `badge` contribution (or tree-row +slots). That is a new surface type. Out of scope here. + +## Tier 2 — needs slots or new view types + +Typed Workbench slots (header actions, tree badges, context menus, detail +panels, status items) are designed in the architecture doc and are not +implemented. Do not pretend they shipped. + +| Idea | Gap | +| --- | --- | +| Risk Radar | New declarative `list` / `badge` view, or tree-row badges. | +| Ownership / CODEOWNERS badges | Tree-row slot + a read of CODEOWNERS (new capability or first-party). | +| Review-queue decorations | Slot inside `strand.review.workspace`, not a third-party React tree. | +| Header actions on Files / Review | Header/toolbar slot. | + +## Tier 3 — needs `network.fetch` and/or isolated UI + +`network.fetch` is named in the manifest and thrown as missing by the +broker. Enabling it is a separate trust-boundary project. + +| Idea | Gap | +| --- | --- | +| CI status panels | Brokered network to GitHub/Azure/etc.; likely a `status`/`list` surface plus host auth that plugins must never see raw. | +| Extra hosted-PR widgets | Same network + slots inside Pull Requests. | +| Secret sniffer (remote rules) | Local heuristics can live in Recap's risky-path list; remote rule packs need fetch + quotas. | + +## Tier 4 — needs isolation (webview or WASI) + +Custom UI in the main webview is rejected. Isolated custom UI and backend +are architecture phases 7, not this ticket. + +| Idea | Gap | +| --- | --- | +| Arbitrary React dashboards | Unprivileged webview + schema-validated RPC. | +| Community git helpers / WASM tools | Capability-limited WASI; no `dlopen` into Strand. | +| Remote marketplace client | Signing, consent, revocation, then isolation. | + +## Follow-ups (not this PR) + +- Risk Radar after a `list`/`badge` primitive exists. +- Enable `network.fetch` only with host allowlists and quotas. +- Remote marketplace only after isolation is proven, in the order above. diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 98e77d1..eb60d8e 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -155,6 +155,20 @@ export const en = { 'plugins.heroi.imageType': 'Heroi can only attach PNG, JPEG, GIF, or WebP images.', 'plugins.heroi.imageSize': 'Each attached image must be 4 MiB or smaller.', 'plugins.heroi.imageReadFailed': 'Heroi could not read an attached image.', + 'plugins.recap.title': 'Session Recap', + 'plugins.recap.noRepository': 'No repository open', + 'plugins.recap.noRepositoryHint': 'Open a repository or worktree to recap agent changes.', + 'plugins.recap.empty': 'No agent session changes', + 'plugins.recap.emptyHint': 'This worktree is clean. Pin a Review baseline or let an agent edit files, then return here.', + 'plugins.recap.worktree': 'Worktree', + 'plugins.recap.since': 'Session since {short}', + 'plugins.recap.uncommitted': 'Uncommitted', + 'plugins.recap.files': 'Files touched ({count})', + 'plugins.recap.risky': 'Risky areas', + 'plugins.recap.riskyEmpty': 'No auth, secret, or migration paths in this session.', + 'plugins.recap.todos': 'TODOs left behind', + 'plugins.recap.todosEmpty': 'No TODO, FIXME, HACK, or XXX markers in loaded patches.', + 'plugins.recap.todosUnavailable': 'Patches are not loaded yet, so leftover TODOs cannot be scanned.', 'files.createEntry': 'New file or folder', 'files.newFile': 'New file', 'files.newFolder': 'New folder', diff --git a/ui/src/plugins/builtins/agentSessionRecap/AgentSessionRecapView.tsx b/ui/src/plugins/builtins/agentSessionRecap/AgentSessionRecapView.tsx new file mode 100644 index 0000000..4e68f68 --- /dev/null +++ b/ui/src/plugins/builtins/agentSessionRecap/AgentSessionRecapView.tsx @@ -0,0 +1,232 @@ +import { useEffect, useMemo } from 'react'; + +import { t } from '../../../lib/i18n'; +import { useRepo } from '../../../stores/repo'; +import type { PluginCapabilityBroker } from '../../capabilities'; +import type { SurfaceRenderRequest } from '../../../workbench/SurfaceHost'; +import { + HEROI_OPEN_FILE_EVENT, + HEROI_OPEN_REVIEW_EVENT, + type HeroiOpenFileDetail, +} from '../heroi/events'; +import { + buildAgentSessionRecap, + recapKindLabel, + recapMissingPatchPaths, + recapUnloadedPatchKey, + unionDiffs, +} from './recap'; + +function repoName(path: string): string { + return path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || path; +} + +export function AgentSessionRecapView({ + request, + broker, +}: { + request: SurfaceRenderRequest; + broker: PluginCapabilityBroker; +}) { + const path = useRepo((state) => state.activePath); + const meta = useRepo((state) => state.meta); + const status = useRepo((state) => state.status); + const baseline = useRepo((state) => state.baseline); + const unstagedDiffs = useRepo((state) => state.unstagedDiffs); + const stagedDiffs = useRepo((state) => state.stagedDiffs); + const baselineDiffs = useRepo((state) => state.baselineDiffs); + const reviewUnstagedDiffs = useRepo((state) => state.reviewUnstagedDiffs); + const visible = request.lifecycle.visible; + const canReadRepo = broker.has('repository.read'); + const reviewPool = baseline ? baselineDiffs : reviewUnstagedDiffs; + const unloadedKey = recapUnloadedPatchKey(unstagedDiffs, stagedDiffs, reviewPool); + + useEffect(() => { + if (!visible || !path || !canReadRepo) return; + const state = useRepo.getState(); + const releaseReview = state.retainDiffs(path, 'review'); + const releaseLocal = state.retainDiffs(path, 'local'); + void Promise.all([state.refreshDiffs(), state.refreshReviewDiffs()]) + .catch((error) => console.warn('session recap diff refresh failed', error)); + return () => { + releaseReview(); + releaseLocal(); + }; + }, [visible, path, baseline?.oid, canReadRepo]); + + useEffect(() => { + if (!visible || !path || !canReadRepo) return; + const current = useRepo.getState(); + const review = current.baseline ? current.baselineDiffs : current.reviewUnstagedDiffs; + const missing = recapMissingPatchPaths(current.unstagedDiffs, current.stagedDiffs, review); + if (!missing.unstaged.length && !missing.staged.length && !missing.review.length) return; + let cancelled = false; + void Promise.all([ + missing.unstaged.length ? current.loadDiffFiles('unstaged', missing.unstaged) : Promise.resolve(), + missing.staged.length ? current.loadDiffFiles('staged', missing.staged) : Promise.resolve(), + missing.review.length ? current.loadDiffFiles('review', missing.review) : Promise.resolve(), + ]).catch((error) => { + if (!cancelled) console.warn('session recap diff load failed', error); + }); + return () => { + cancelled = true; + }; + }, [visible, path, baseline?.oid, canReadRepo, unloadedKey]); + + const recap = useMemo(() => { + const repo = path && canReadRepo + ? { + path, + name: meta?.name ?? repoName(path), + branch: meta?.branch ?? null, + head: meta?.head_oid ?? null, + dirty: status.length > 0, + } + : null; + return buildAgentSessionRecap({ + repo, + linkedWorktree: meta?.is_linked_worktree ?? false, + baselineShort: baseline?.short ?? null, + status: canReadRepo ? status : [], + diffs: canReadRepo ? unionDiffs(unstagedDiffs, stagedDiffs, reviewPool) : [], + }); + }, [ + path, + meta, + status, + baseline, + unstagedDiffs, + stagedDiffs, + reviewPool, + canReadRepo, + ]); + + const openFile = (filePath: string) => { + if (!path) return; + const detail: HeroiOpenFileDetail = { projectPath: path, path: filePath }; + window.dispatchEvent(new CustomEvent(HEROI_OPEN_FILE_EVENT, { detail })); + }; + + if (recap.state === 'no-repository') { + return ( +
+
+ {t('plugins.recap.noRepository')} + {t('plugins.recap.noRepositoryHint')} +
+
+ ); + } + + if (recap.state === 'empty') { + return ( +
+
+ {t('plugins.recap.empty')} + {t('plugins.recap.emptyHint')} +
+ +
+ ); + } + + return ( +
+
+
+ {t('plugins.recap.title')} + + {recap.repoName} + {recap.branch ? ` · ${recap.branch}` : ''} + +
+
+ {recap.linkedWorktree && {t('plugins.recap.worktree')}} + {recap.baselineShort + ? {t('plugins.recap.since', { short: recap.baselineShort })} + : {t('plugins.recap.uncommitted')}} +
+
+ +
+
+

{t('plugins.recap.files', { count: recap.files.length })}

+
    + {recap.files.map((file) => ( +
  • + +
  • + ))} +
+
+ +
+

{t('plugins.recap.risky')}

+ {recap.risky.length === 0 ? ( +

{t('plugins.recap.riskyEmpty')}

+ ) : ( +
    + {recap.risky.map((file) => ( +
  • + +
  • + ))} +
+ )} +
+ +
+

{t('plugins.recap.todos')}

+ {recap.todos.length === 0 ? ( +

+ {recap.patchesScanned === 0 + ? t('plugins.recap.todosUnavailable') + : t('plugins.recap.todosEmpty')} +

+ ) : ( +
    + {recap.todos.map((todo, index) => ( +
  • + +
  • + ))} +
+ )} +
+
+ +
+ +
+
+ ); +} diff --git a/ui/src/plugins/builtins/agentSessionRecap/manifest.ts b/ui/src/plugins/builtins/agentSessionRecap/manifest.ts new file mode 100644 index 0000000..676ad39 --- /dev/null +++ b/ui/src/plugins/builtins/agentSessionRecap/manifest.ts @@ -0,0 +1,28 @@ +import type { PluginManifest } from '../../manifest'; + +export const SESSION_RECAP_SURFACE_ID = 'daniels.session-recap.workspace' as const; + +export const sessionRecapManifest: PluginManifest = { + id: 'daniels.session-recap', + name: 'Agent Session Recap', + version: '0.1.0', + apiVersion: '1', + description: 'Summarize what an agent changed in the active worktree: files touched, risky paths, and TODOs left behind.', + author: 'Daniels', + permissions: ['repository.read'], + contributes: { + surfaces: [ + { + id: 'workspace', + title: 'Session Recap', + description: 'Files, risky areas, and leftover TODOs for the active repository or worktree, next to Review.', + icon: 'compare', + scope: 'repository', + hosts: ['main', 'panel', 'sidebar', 'bottom'], + instancePolicy: 'singleton', + lifecycle: 'unmount', + render: { kind: 'builtin', module: 'daniels.session-recap.workspace' }, + }, + ], + }, +}; diff --git a/ui/src/plugins/builtins/agentSessionRecap/recap.test.ts b/ui/src/plugins/builtins/agentSessionRecap/recap.test.ts new file mode 100644 index 0000000..50ffe17 --- /dev/null +++ b/ui/src/plugins/builtins/agentSessionRecap/recap.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from 'vitest'; + +import type { FileDiff, FileStatus } from '../../../lib/types'; +import type { RepositorySnapshot } from '../../capabilities'; +import { + buildAgentSessionRecap, + classifyRiskyPath, + diffStatusToRecap, + extractTodosFromPatch, + recapKindLabel, + recapMissingPatchPaths, + recapUnloadedPatchKey, + RECAP_PATCH_SCAN_LIMIT, + statusKindToRecap, + unionDiffs, + uniqueDiffs, +} from './recap'; + +const repo: RepositorySnapshot = { + path: '/src/strand', + name: 'strand', + branch: 'agent/fix', + head: 'abc1234', + dirty: true, +}; + +function status(path: string, kind: FileStatus['kind'] = 'MODIFIED'): FileStatus { + return { path, kind, staged: false }; +} + +function diff(path: string, patch: string, loaded = true): FileDiff { + return { + path, + old_path: null, + status: 'modified', + adds: 1, + dels: 0, + binary: false, + patch, + patchLoaded: loaded, + revision: path, + }; +} + +describe('classifyRiskyPath', () => { + it('flags env, keys, auth, and migrations without matching author/keyboard', () => { + expect(classifyRiskyPath('.env')).toBe(true); + expect(classifyRiskyPath('.env.local')).toBe(true); + expect(classifyRiskyPath('deploy/id_ed25519')).toBe(true); + expect(classifyRiskyPath('certs/prod.pem')).toBe(true); + expect(classifyRiskyPath('src/auth/session.ts')).toBe(true); + expect(classifyRiskyPath('db/migrations/001.sql')).toBe(true); + expect(classifyRiskyPath('src/keyboard.ts')).toBe(false); + expect(classifyRiskyPath('src/author.ts')).toBe(false); + expect(classifyRiskyPath('package.json')).toBe(false); + }); +}); + +describe('extractTodosFromPatch', () => { + it('reads added TODO markers and ignores deletions and file headers', () => { + const patch = [ + '--- a/src/app.ts', + '+++ b/src/app.ts', + '@@ -1,3 +1,4 @@', + '-// TODO: old leftover', + '+function run() {', + '+ // TODO: wire the broker', + '+ // FIXME follow up', + ' keep', + ].join('\n'); + expect(extractTodosFromPatch('src/app.ts', patch)).toEqual([ + { path: 'src/app.ts', text: 'TODO: wire the broker' }, + { path: 'src/app.ts', text: 'FIXME: follow up' }, + ]); + }); +}); + +describe('buildAgentSessionRecap', () => { + it('returns a no-repository state without crashing', () => { + const recap = buildAgentSessionRecap({ + repo: null, + linkedWorktree: false, + baselineShort: null, + status: [status('src/app.ts')], + diffs: [], + }); + expect(recap.state).toBe('no-repository'); + expect(recap.files).toEqual([]); + }); + + it('returns empty when the worktree has no files or diffs', () => { + const recap = buildAgentSessionRecap({ + repo: { ...repo, dirty: false }, + linkedWorktree: true, + baselineShort: 'ab12cd', + status: [], + diffs: [], + }); + expect(recap.state).toBe('empty'); + expect(recap.linkedWorktree).toBe(true); + expect(recap.baselineShort).toBe('ab12cd'); + }); + + it('unions status and review diffs, flags risky paths, and scans loaded patches', () => { + const recap = buildAgentSessionRecap({ + repo, + linkedWorktree: true, + baselineShort: 'ab12cd', + status: [ + status('src/app.ts'), + status('src/auth/login.ts', 'ADDED'), + status('notes.md', 'UNTRACKED'), + ], + diffs: [ + diff('src/app.ts', '+// TODO: leftover\n keep'), + diff('src/committed.ts', '', false), + { + ...diff('legacy.ts', '+const x = 1'), + status: 'deleted', + }, + ], + }); + expect(recap.state).toBe('ready'); + expect(recap.files.map((file) => file.path)).toEqual([ + 'legacy.ts', + 'notes.md', + 'src/app.ts', + 'src/auth/login.ts', + 'src/committed.ts', + ]); + expect(recap.risky.map((file) => file.path)).toEqual(['src/auth/login.ts']); + expect(recap.todos).toEqual([{ path: 'src/app.ts', text: 'TODO: leftover' }]); + expect(recap.patchesScanned).toBe(2); + }); + + it('keeps TODOs from a loaded local patch when a review summary shares the path', () => { + const recap = buildAgentSessionRecap({ + repo, + linkedWorktree: false, + baselineShort: 'ab12cd', + status: [status('src/app.ts')], + diffs: uniqueDiffs( + [diff('src/app.ts', '+// TODO: local leftover')], + [diff('src/app.ts', '', false)], + ), + }); + expect(recap.todos).toEqual([{ path: 'src/app.ts', text: 'TODO: local leftover' }]); + expect(recap.patchesScanned).toBe(1); + }); + + it('scans TODOs from both sides of a partial stage', () => { + const recap = buildAgentSessionRecap({ + repo, + linkedWorktree: false, + baselineShort: null, + status: [status('src/app.ts')], + diffs: unionDiffs( + [diff('src/app.ts', '+// TODO: unstaged side')], + [diff('src/app.ts', '+// TODO: staged side')], + [diff('src/app.ts', '', false)], + ), + }); + expect(recap.todos).toEqual([ + { path: 'src/app.ts', text: 'TODO: unstaged side' }, + { path: 'src/app.ts', text: 'TODO: staged side' }, + ]); + expect(recap.patchesScanned).toBe(2); + }); +}); + +describe('uniqueDiffs', () => { + it('prefers a loaded patch over a later review summary', () => { + const loaded = diff('src/app.ts', '+// TODO: keep'); + const summary = diff('src/app.ts', '', false); + expect(uniqueDiffs([loaded], [summary])).toEqual([loaded]); + expect(uniqueDiffs([summary], [loaded])).toEqual([loaded]); + }); +}); + +describe('recapMissingPatchPaths', () => { + it('requests review-only paths when local pools are empty', () => { + expect(recapMissingPatchPaths([], [], [diff('committed.ts', '', false)])).toEqual({ + unstaged: [], + staged: [], + review: ['committed.ts'], + }); + }); + + it('does not drop the unloaded unstaged side of a partial stage', () => { + expect(recapMissingPatchPaths( + [diff('src/app.ts', '', false)], + [diff('src/app.ts', '+already loaded', true)], + [], + )).toEqual({ + unstaged: ['src/app.ts'], + staged: [], + review: [], + }); + }); + + it('caps each pool at RECAP_PATCH_SCAN_LIMIT', () => { + const review = Array.from({ length: RECAP_PATCH_SCAN_LIMIT + 4 }, (_, index) => ( + diff(`file-${index}.ts`, '', false) + )); + expect(recapMissingPatchPaths([], [], review).review).toHaveLength(RECAP_PATCH_SCAN_LIMIT); + }); +}); + +describe('recapUnloadedPatchKey', () => { + it('changes when a loaded path becomes unloaded so the loader re-runs', () => { + const loaded = recapUnloadedPatchKey([diff('src/app.ts', '+keep', true)], [], []); + const unloaded = recapUnloadedPatchKey([diff('src/app.ts', '', false)], [], []); + expect(loaded).toBe(''); + expect(unloaded).not.toBe(loaded); + expect(unloaded).toContain('src/app.ts'); + }); + + it('changes when a new unloaded path appears', () => { + const before = recapUnloadedPatchKey([diff('a.ts', '', false)], [], []); + const after = recapUnloadedPatchKey( + [diff('a.ts', '', false), diff('b.ts', '', false)], + [], + [], + ); + expect(before).not.toBe(after); + }); +}); + +describe('kind mapping', () => { + it('maps every status and diff variant', () => { + expect(statusKindToRecap('ADDED')).toBe('added'); + expect(statusKindToRecap('MODIFIED')).toBe('modified'); + expect(statusKindToRecap('DELETED')).toBe('deleted'); + expect(statusKindToRecap('RENAMED')).toBe('renamed'); + expect(statusKindToRecap('UNTRACKED')).toBe('untracked'); + expect(statusKindToRecap('CONFLICTED')).toBe('conflicted'); + expect(diffStatusToRecap('copied')).toBe('modified'); + expect(diffStatusToRecap('typechange')).toBe('modified'); + expect(recapKindLabel('conflicted')).toBe('conflicted'); + }); +}); diff --git a/ui/src/plugins/builtins/agentSessionRecap/recap.ts b/ui/src/plugins/builtins/agentSessionRecap/recap.ts new file mode 100644 index 0000000..fbe5698 --- /dev/null +++ b/ui/src/plugins/builtins/agentSessionRecap/recap.ts @@ -0,0 +1,284 @@ +import type { DiffStatus, FileDiff, FileStatus, StatusKind } from '../../../lib/types'; +import type { RepositorySnapshot } from '../../capabilities'; + +export type RecapState = 'no-repository' | 'empty' | 'ready'; + +export type RecapFileKind = + | 'added' + | 'modified' + | 'deleted' + | 'renamed' + | 'untracked' + | 'conflicted'; + +export interface RecapFile { + path: string; + kind: RecapFileKind; +} + +export interface RecapTodo { + path: string; + text: string; +} + +export interface RecapModel { + state: RecapState; + repoName: string | null; + branch: string | null; + head: string | null; + linkedWorktree: boolean; + dirty: boolean; + baselineShort: string | null; + files: RecapFile[]; + risky: RecapFile[]; + todos: RecapTodo[]; + patchesScanned: number; +} + +export interface RecapInput { + repo: RepositorySnapshot | null; + linkedWorktree: boolean; + baselineShort: string | null; + status: readonly FileStatus[]; + diffs: readonly FileDiff[]; +} + +export const RECAP_FILE_LIMIT = 64; +export const RECAP_RISKY_LIMIT = 16; +export const RECAP_TODO_LIMIT = 16; +/** Bound patch reads so Recap cannot become a background whole-tree loader. */ +export const RECAP_PATCH_SCAN_LIMIT = 16; + +const RISK_SEGMENT = /^(auth|oauth|jwt|crypto|passwd|password|secret|secrets|credential|credentials|payment|billing|permission|permissions|rbac|kms)$/; +const TODO_RE = /\b(TODO|FIXME|HACK|XXX)\b[:\s-]*(.*)$/i; + +export function statusKindToRecap(kind: StatusKind): RecapFileKind { + switch (kind) { + case 'ADDED': + return 'added'; + case 'MODIFIED': + return 'modified'; + case 'DELETED': + return 'deleted'; + case 'RENAMED': + return 'renamed'; + case 'UNTRACKED': + return 'untracked'; + case 'CONFLICTED': + return 'conflicted'; + default: { + const exhaustive: never = kind; + return exhaustive; + } + } +} + +export function diffStatusToRecap(status: DiffStatus): RecapFileKind { + switch (status) { + case 'added': + return 'added'; + case 'modified': + return 'modified'; + case 'deleted': + return 'deleted'; + case 'renamed': + return 'renamed'; + case 'copied': + return 'modified'; + case 'typechange': + return 'modified'; + default: { + const exhaustive: never = status; + return exhaustive; + } + } +} + +/** Path heuristics only — not a secret sniffer and not Risk Radar. */ +export function classifyRiskyPath(path: string): boolean { + const normalized = path.replace(/\\/g, '/').toLowerCase(); + const base = normalized.split('/').pop() ?? normalized; + if (/^\.env(\.|$)/.test(base)) return true; + if (/\.(pem|p12|pfx|key)$/.test(base)) return true; + if (/^(id_rsa|id_ed25519|id_ecdsa|id_dsa)(\.|$)/.test(base)) return true; + if (/(^|\/)(\.ssh|secrets?|credentials?)(\/|$)/.test(normalized)) return true; + const segments = normalized.split('/'); + if (segments.some((segment) => RISK_SEGMENT.test(segment))) return true; + if (segments.includes('migrations') || segments.includes('migrate')) return true; + return false; +} + +export function extractTodosFromPatch(path: string, patch: string): RecapTodo[] { + if (!patch) return []; + const todos: RecapTodo[] = []; + for (const line of patch.split('\n')) { + if (!line.startsWith('+') || line.startsWith('+++')) continue; + const match = TODO_RE.exec(line.slice(1)); + if (!match) continue; + const marker = match[1].toUpperCase(); + const detail = match[2].trim(); + todos.push({ path, text: detail ? `${marker}: ${detail}` : marker }); + } + return todos; +} + +function patchIsLoaded(diff: FileDiff): boolean { + return diff.patchLoaded !== false; +} + +/** One row per path. A loaded patch wins over a later summary (`patchLoaded: false`). */ +export function uniqueDiffs(...pools: readonly (readonly FileDiff[])[]): FileDiff[] { + const byPath = new Map(); + for (const pool of pools) { + for (const diff of pool) { + const previous = byPath.get(diff.path); + if (!previous) { + byPath.set(diff.path, diff); + continue; + } + if (patchIsLoaded(diff) && !patchIsLoaded(previous)) byPath.set(diff.path, diff); + } + } + return [...byPath.values()]; +} + +/** Every pool row, including both sides of a partial stage. */ +export function unionDiffs(...pools: readonly (readonly FileDiff[])[]): FileDiff[] { + const rows: FileDiff[] = []; + for (const pool of pools) { + for (const diff of pool) rows.push(diff); + } + return rows; +} + +function unloadedPaths(pool: readonly FileDiff[], limit: number): string[] { + const paths: string[] = []; + for (const diff of pool) { + if (diff.patchLoaded !== false) continue; + paths.push(diff.path); + if (paths.length >= limit) break; + } + return paths; +} + +/** Per-pool missing patches so a loaded staged side cannot hide an unloaded unstaged side. */ +export function recapMissingPatchPaths( + unstaged: readonly FileDiff[], + staged: readonly FileDiff[], + review: readonly FileDiff[], + limit = RECAP_PATCH_SCAN_LIMIT, +): { unstaged: string[]; staged: string[]; review: string[] } { + return { + unstaged: unloadedPaths(unstaged, limit), + staged: unloadedPaths(staged, limit), + review: unloadedPaths(review, limit), + }; +} + +/** Effect dep: changes when an unloaded path appears or a loaded path is reset. */ +export function recapUnloadedPatchKey( + unstaged: readonly FileDiff[], + staged: readonly FileDiff[], + review: readonly FileDiff[], +): string { + const parts: string[] = []; + const push = (kind: string, pool: readonly FileDiff[]) => { + for (const diff of pool) { + if (diff.patchLoaded === false) parts.push(`${kind}:${diff.path}`); + } + }; + push('unstaged', unstaged); + push('staged', staged); + push('review', review); + return parts.sort().join('\0'); +} + +function mergeFile(previous: RecapFile | undefined, next: RecapFile): RecapFile { + if (!previous) return next; + if (previous.kind === 'conflicted' || next.kind === 'conflicted') { + return { path: next.path, kind: 'conflicted' }; + } + if (previous.kind === 'untracked') return next; + return previous; +} + +export function buildAgentSessionRecap(input: RecapInput): RecapModel { + if (!input.repo) { + return { + state: 'no-repository', + repoName: null, + branch: null, + head: null, + linkedWorktree: false, + dirty: false, + baselineShort: null, + files: [], + risky: [], + todos: [], + patchesScanned: 0, + }; + } + + const byPath = new Map(); + for (const row of input.status) { + byPath.set(row.path, mergeFile(byPath.get(row.path), { + path: row.path, + kind: statusKindToRecap(row.kind), + })); + } + for (const diff of input.diffs) { + byPath.set(diff.path, mergeFile(byPath.get(diff.path), { + path: diff.path, + kind: diffStatusToRecap(diff.status), + })); + } + + const files = [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path)); + const risky = files.filter((file) => classifyRiskyPath(file.path)).slice(0, RECAP_RISKY_LIMIT); + const todos: RecapTodo[] = []; + let patchesScanned = 0; + for (const diff of input.diffs) { + if (diff.patchLoaded === false || !diff.patch) continue; + patchesScanned += 1; + if (todos.length >= RECAP_TODO_LIMIT) continue; + for (const todo of extractTodosFromPatch(diff.path, diff.patch)) { + if (todos.length >= RECAP_TODO_LIMIT) break; + todos.push(todo); + } + } + + return { + state: files.length === 0 ? 'empty' : 'ready', + repoName: input.repo.name, + branch: input.repo.branch, + head: input.repo.head, + linkedWorktree: input.linkedWorktree, + dirty: input.repo.dirty || input.status.length > 0, + baselineShort: input.baselineShort, + files: files.slice(0, RECAP_FILE_LIMIT), + risky, + todos, + patchesScanned, + }; +} + +export function recapKindLabel(kind: RecapFileKind): string { + switch (kind) { + case 'added': + return 'added'; + case 'modified': + return 'modified'; + case 'deleted': + return 'deleted'; + case 'renamed': + return 'renamed'; + case 'untracked': + return 'untracked'; + case 'conflicted': + return 'conflicted'; + default: { + const exhaustive: never = kind; + return exhaustive; + } + } +} diff --git a/ui/src/plugins/index.ts b/ui/src/plugins/index.ts index cca8ed1..ece6629 100644 --- a/ui/src/plugins/index.ts +++ b/ui/src/plugins/index.ts @@ -29,3 +29,4 @@ export { } from './registry'; export { renderPluginSurface, isPluginSurface } from './renderSurface'; export { HEROI_SURFACE_ID, heroiManifest } from './builtins/heroi/manifest'; +export { SESSION_RECAP_SURFACE_ID, sessionRecapManifest } from './builtins/agentSessionRecap/manifest'; diff --git a/ui/src/plugins/manifest.ts b/ui/src/plugins/manifest.ts index de5d22e..4f5b8e3 100644 --- a/ui/src/plugins/manifest.ts +++ b/ui/src/plugins/manifest.ts @@ -38,7 +38,7 @@ export interface PluginSurfaceManifest { lifecycle: SurfaceLifecyclePolicy; render: | { kind: 'declarative'; view: DeclarativeView } - | { kind: 'builtin'; module: 'daniels.heroi.workspace' | 'strand-tools.quick-notes.workspace' }; + | { kind: 'builtin'; module: 'daniels.heroi.workspace' | 'strand-tools.quick-notes.workspace' | 'daniels.session-recap.workspace' }; } export interface PluginCommandManifest { @@ -155,7 +155,11 @@ function validateSurfaceManifest(manifest: PluginManifest, surface: PluginSurfac if (surface.render.kind === 'declarative') { validateDeclarativeView(surface.render.view); } else if (surface.render.kind === 'builtin') { - if (surface.render.module !== 'daniels.heroi.workspace' && surface.render.module !== 'strand-tools.quick-notes.workspace') { + if ( + surface.render.module !== 'daniels.heroi.workspace' + && surface.render.module !== 'strand-tools.quick-notes.workspace' + && surface.render.module !== 'daniels.session-recap.workspace' + ) { throw new PluginManifestError(`Unknown builtin module "${String(surface.render.module)}".`); } if (surface.render.module === 'daniels.heroi.workspace' && manifest.id !== 'daniels.heroi') { @@ -164,6 +168,9 @@ function validateSurfaceManifest(manifest: PluginManifest, surface: PluginSurfac if (surface.render.module === 'strand-tools.quick-notes.workspace' && manifest.id !== 'example.quick-notes') { throw new PluginManifestError('The Quick Notes builtin module is reserved for example.quick-notes.'); } + if (surface.render.module === 'daniels.session-recap.workspace' && manifest.id !== 'daniels.session-recap') { + throw new PluginManifestError('The Session Recap builtin module is reserved for daniels.session-recap.'); + } } else { throw new PluginManifestError(`Surface "${surface.id}" has an invalid render kind.`); } diff --git a/ui/src/plugins/marketplace.ts b/ui/src/plugins/marketplace.ts index 59fdde3..73d03b8 100644 --- a/ui/src/plugins/marketplace.ts +++ b/ui/src/plugins/marketplace.ts @@ -1,5 +1,6 @@ import type { PluginManifest } from './manifest'; import { heroiManifest } from './builtins/heroi/manifest'; +import { sessionRecapManifest } from './builtins/agentSessionRecap/manifest'; import { quickNotesManifest } from './builtins/quickNotes/manifest'; export interface MarketplaceEntry { @@ -16,6 +17,11 @@ export const MARKETPLACE_CATALOG: readonly MarketplaceEntry[] = [ builtin: true, tags: ['agents', 'orchestrator', 'experimental'], }, + { + manifest: sessionRecapManifest, + builtin: true, + tags: ['agents', 'review'], + }, { manifest: quickNotesManifest, builtin: true, diff --git a/ui/src/plugins/plugins.test.ts b/ui/src/plugins/plugins.test.ts index 76822ce..314b269 100644 --- a/ui/src/plugins/plugins.test.ts +++ b/ui/src/plugins/plugins.test.ts @@ -4,6 +4,7 @@ import { PluginCapabilityBroker, PluginPermissionError } from './capabilities'; import { validatePluginManifest, PLUGIN_API_VERSION } from './manifest'; import { MARKETPLACE_CATALOG } from './marketplace'; import { PluginRegistry } from './registry'; +import { sessionRecapManifest } from './builtins/agentSessionRecap/manifest'; import { heroiManifest } from './builtins/heroi/manifest'; import { quickNotesManifest } from './builtins/quickNotes/manifest'; @@ -49,6 +50,13 @@ describe('validatePluginManifest', () => { contributes: { surfaces: [] }, })).toThrow('strand.* namespace is reserved'); }); + + it('reserves the Session Recap builtin module', () => { + expect(() => validatePluginManifest({ + ...sessionRecapManifest, + id: 'example.not-recap', + })).toThrow('Session Recap builtin module is reserved'); + }); }); describe('MARKETPLACE_CATALOG', () => { @@ -66,9 +74,18 @@ describe('MARKETPLACE_CATALOG', () => { expect(heroi?.manifest.description).not.toMatch(/Aider/); expect(heroiManifest.contributes.commands?.[0]?.id).toBe('new-conversation'); expect(ids).toContain('example.quick-notes'); + expect(ids).toContain('daniels.session-recap'); expect(ids).not.toContain('example.repo-status'); expect(MARKETPLACE_CATALOG.find((entry) => entry.manifest.id === 'example.quick-notes')?.builtin).toBe(true); expect(quickNotesManifest.contributes.surfaces[0]?.scope).toBe('repository'); + const recap = MARKETPLACE_CATALOG.find((entry) => entry.manifest.id === 'daniels.session-recap'); + expect(recap?.builtin).toBe(true); + expect(recap?.manifest.permissions).toEqual(['repository.read']); + expect(sessionRecapManifest.contributes.surfaces[0]?.render).toEqual({ + kind: 'builtin', + module: 'daniels.session-recap.workspace', + }); + expect(validatePluginManifest(sessionRecapManifest).id).toBe('daniels.session-recap'); }); }); @@ -80,6 +97,14 @@ describe('PluginRegistry', () => { registry.uninstall('daniels.heroi'); expect(registry.getSurfaceRegistry().get('daniels.heroi.workspace')).toBeUndefined(); }); + + it('registers Agent Session Recap as a workbench surface', () => { + const registry = new PluginRegistry(); + registry.install(sessionRecapManifest); + expect(registry.getSurfaceRegistry().get('daniels.session-recap.workspace')?.title).toBe('Session Recap'); + expect(registry.createBroker('daniels.session-recap').has('repository.read')).toBe(true); + expect(registry.createBroker('daniels.session-recap').has('ai.invoke')).toBe(false); + }); }); describe('PluginCapabilityBroker', () => { diff --git a/ui/src/plugins/renderSurface.tsx b/ui/src/plugins/renderSurface.tsx index 16cd3a4..00d2f74 100644 --- a/ui/src/plugins/renderSurface.tsx +++ b/ui/src/plugins/renderSurface.tsx @@ -1,7 +1,6 @@ import { lazy, Suspense } from 'react'; -import { HEROI_SURFACE_ID } from './builtins/heroi/manifest'; +import { AgentSessionRecapView } from './builtins/agentSessionRecap/AgentSessionRecapView'; import { QuickNotesView } from './builtins/quickNotes/QuickNotesView'; -import { QUICK_NOTES_SURFACE_ID } from './builtins/quickNotes/manifest'; import type { DeclarativeView } from './manifest'; import { pluginRegistry } from './registry'; import type { PluginCapabilityBroker } from './capabilities'; @@ -31,24 +30,29 @@ function DeclarativePluginView({ ); } - return ( -
-
- {view.title} - {broker.has('repository.read') && request.binding.kind === 'follow-active' && ( - {t('plugins.repoContext')} - )} -
-
- {view.items.map((item) => ( -
-
{item.label}
-
{item.value}
-
- ))} -
-
- ); + if (view.type === 'status') { + return ( +
+
+ {view.title} + {broker.has('repository.read') && request.binding.kind === 'follow-active' && ( + {t('plugins.repoContext')} + )} +
+
+ {view.items.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+
+ ); + } + + const exhaustive: never = view; + return exhaustive; } export function renderPluginSurface(request: SurfaceRenderRequest): ReactNode { @@ -58,22 +62,27 @@ export function renderPluginSurface(request: SurfaceRenderRequest): ReactNode { const render = binding.surface.render; if (render.kind === 'builtin') { - if (request.contribution.id === HEROI_SURFACE_ID) { - return Loading…}> - - ; - } - if (request.contribution.id === QUICK_NOTES_SURFACE_ID) { - return ; + switch (render.module) { + case 'daniels.heroi.workspace': + return Loading…}> + + ; + case 'strand-tools.quick-notes.workspace': + return ; + case 'daniels.session-recap.workspace': + return ; + default: { + const exhaustive: never = render.module; + return ( +
+
+ {t('workbench.surfaceUnavailable')} + {exhaustive} +
+
+ ); + } } - return ( -
-
- {t('workbench.surfaceUnavailable')} - {request.contribution.id} -
-
- ); } return ( diff --git a/ui/src/styles/features.css b/ui/src/styles/features.css index c39231b..286fcdb 100644 --- a/ui/src/styles/features.css +++ b/ui/src/styles/features.css @@ -8965,6 +8965,104 @@ select.clone-input { .plugin-quick-notes textarea::placeholder { color: var(--text-muted); } .plugin-quick-notes textarea:focus-visible { box-shadow: inset 0 0 0 1px var(--accent); } +.plugin-session-recap { + flex: 1 1 auto; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + background: var(--bg); +} +.plugin-session-recap-head { + flex: none; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-bottom: 1px solid var(--border); +} +.plugin-session-recap-head strong { font-size: 13px; } +.plugin-session-recap-head span { + display: block; + min-width: 0; + overflow: hidden; + color: var(--text-muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} +.plugin-session-recap-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: flex-end; +} +.plugin-session-recap-body { + flex: 1; + min-height: 0; + overflow: auto; + padding: 12px; + display: grid; + gap: 16px; + align-content: start; +} +.plugin-session-recap-body h2 { + margin: 0 0 8px; + color: var(--text-muted); + font-size: 11px; + font-weight: 650; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.plugin-session-recap-files, +.plugin-session-recap-todos { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 2px; +} +.plugin-session-recap-file { + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + padding: 6px 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text); + text-align: left; + font: 12px/1.4 var(--font-ui); + cursor: pointer; +} +.plugin-session-recap-file:hover, +.plugin-session-recap-file:focus-visible { background: var(--bg-hover); } +.plugin-session-recap-file:focus-visible { outline: none; box-shadow: inset 0 0 0 1px var(--accent); } +.plugin-session-recap-file span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.plugin-session-recap-file span:last-child { color: var(--text-muted); font-size: 11px; } +.plugin-session-recap-file.risky span:last-child { color: var(--warn); } +.plugin-session-recap-muted { + margin: 0; + color: var(--text-muted); + font-size: 12px; + line-height: 1.5; +} +.plugin-session-recap-foot { + flex: none; + padding: 10px 12px; + border-top: 1px solid var(--border); +} +.plugin-session-recap-empty { justify-content: center; align-items: center; } + /* Heroi stays chat-only; the Workbench owns Files, Review, and diff surfaces. */ .plugin-heroi-chat-only { width: 100%; diff --git a/website/docs/custom-view.md b/website/docs/custom-view.md index 2aba0d1..5a595a3 100644 --- a/website/docs/custom-view.md +++ b/website/docs/custom-view.md @@ -88,7 +88,9 @@ survive. Workbench currently supports built-in Strand surfaces, installed plugin surfaces from Settings → Plugins (including the Heroi dogfood plugin, which provides chats scoped to the active repository and runs Claude, Codex, or -Cursor Agent in the background, and Quick Notes, which saves a separate +Cursor Agent in the background; Agent Session Recap, which lists files touched, +risky paths, and leftover TODOs in the active worktree next to Review; and +Quick Notes, which saves a separate scratchpad for each repository in Strand's app data), and a 32-pane defensive limit. Heroi does not duplicate Files, git changes, or diffs; add those as separate Workbench panes. diff --git a/website/docs/keyboard-and-palette.md b/website/docs/keyboard-and-palette.md index 2ebd19a..4b89edc 100644 --- a/website/docs/keyboard-and-palette.md +++ b/website/docs/keyboard-and-palette.md @@ -174,6 +174,10 @@ tool group, then a row, to inspect bounded command details and output. Click a path in the file list to open it in Work's Changes view. Quick Launch also has **Heroi: New conversation**. +### Session Recap plugin surface + +After installing Agent Session Recap from Settings → Plugins, assign **Session Recap** to a Workbench pane. File rows and **Open review** are in the `Tab` order; `Enter` activates the focused control. Quick Launch includes **Workbench: show Session Recap** once the plugin is installed. + ### Repository tabs When repository navigation uses the horizontal tab strip, focus a tab to use: diff --git a/website/docs/reviewing-agent-changes.md b/website/docs/reviewing-agent-changes.md index ac06a18..aac2ef8 100644 --- a/website/docs/reviewing-agent-changes.md +++ b/website/docs/reviewing-agent-changes.md @@ -114,3 +114,7 @@ See [Worktrees](worktrees.md) for creating worktrees, comparing multiple attempt If your product spans several repositories grouped into a workspace, Workspace Review (`Mod+7`) aggregates every member repository — and every open worktree tab of a member — into one merged review queue with the same keys, notes, and feedback export. Members whose folder was deleted or moved are skipped until the path is a repository again. See [Repositories and workspaces](repositories-and-workspaces.md). Each member's inbox includes staged and unstaged changes together. Staging a file keeps it in the queue; partially staged files remain read-only at hunk level. A failed member refresh keeps its last comparison visible with an error and retry action. + +## Session Recap plugin + +Install **Agent Session Recap** from Settings → Plugins and place **Session Recap** in a Workbench pane beside Review. With a dirty worktree or a pinned review baseline it lists files touched, flags auth/secret/migration paths, and scans loaded patches for leftover `TODO` / `FIXME` markers. With no repository or no changes it shows an empty state instead of crashing. It does not replace Review — **Open review** jumps to Strand's Review surface. diff --git a/website/docs/settings.md b/website/docs/settings.md index d932522..9507eb2 100644 --- a/website/docs/settings.md +++ b/website/docs/settings.md @@ -392,6 +392,16 @@ issue in your browser for review before anything is submitted: Crashes are always logged locally regardless of the toggle; the section shows the crash-log path and notes that logs can include repository paths, so you can review before sharing. +## Plugins + +Settings → Plugins lists Strand's bundled experimental marketplace. Install a plugin, then add its surface from Workbench customization (`Mod+8`). Third-party JavaScript never runs inside the privileged webview. + +- **Heroi** — repository-scoped coding-agent chat for Claude, Codex, and Cursor Agent. +- **Agent Session Recap** — files touched, risky paths, and leftover TODOs for the active worktree, next to Review. +- **Quick Notes** — a per-repository scratchpad stored in Strand's app database. + +Remote catalog downloads are not available. See [Customize the Workbench](custom-view.md) for placing plugin surfaces. + --- All settings persist across launches, along with the rest of your session — open tabs, Work pane sizes, each workspace's Custom pane sizes and feature layout, per-repo diff layouts, and workspaces. For the full shortcut reference, see [Keyboard and palette](keyboard-and-palette.md). diff --git a/website/index.html b/website/index.html index 4bd3941..73d95bf 100644 --- a/website/index.html +++ b/website/index.html @@ -360,6 +360,7 @@

Files, terminals, and your agent
in one Plugins experimental

Settings → Plugins is a bundled marketplace of Workbench surfaces. Heroi is a repository-scoped chat for background Claude, Codex, and Cursor Agent sessions; + Agent Session Recap lists files, risky paths, and leftover TODOs beside Review; Quick Notes is a per-repository scratchpad saved in Strand's database. Plugins render from validated manifests — third-party JavaScript never runs in the privileged webview.