feat(web): architect-backed AI drawer on /specs/$specName #1127

Merged
reviewer merged 1 commit from code-lead/1119 into main 2026-05-13 14:44:16 +00:00
Collaborator

Closes #1119.

Summary

Wires MarkdownAssistantDrawer into /specs/$specName so operators can iterate on a spec body without leaving the editor. Conversation history is remembered per spec in localStorage.

  • Toolbar + URL state. New Sparkles "Assistant" button in the spec header opens the drawer. State mirrors to ?assistant=open so deep links round-trip. ⌘⇧A toggles via useSpecsAssistantShortcut; the binding is catalogued under a new specs shortcut scope (header shows the chord, help overlay lists it).
  • Per-spec architect memory. First successful chat response writes last_session_id to localStorage under specs:<repo>:<specName>:lastSession. Reopening the drawer feeds the id back via the new initialSessionId / onSessionChange props on MarkdownAssistantDrawer, which calls fetchArchitectSession and shows the prior transcript. New chat clears the key. Stale ids (server purged) fall back to a fresh chat silently.
  • Prompt shape. buildSpecAssistantUserMessage mirrors the skill-assistant prompt: filename header + Repo: line + fenced current body + operator request.
  • Apply path. onApply(next) overwrites the editor draft via a new externalReplace={content,rev} hatch on SpecEditor (no remount — autosave then persists the new body). When the draft is dirty, a Base UI Dialog confirms before clobbering.

Test plan

  • bun run test src/routes/specs.test.tsx — 24 tests pass (11 new + 13 retained from wsplit-2).
  • just qa — typecheck + Biome + paraglide + i18n + sql-layer + flow-schema all clean. Web/server unit suites pass.
  • Manual: open /specs/some-spec?assistant=open, send a message, reload — transcript restores. ⌘⇧A toggles. Dirty the editor, click Insert — confirm dialog appears.

Out of scope (deferred per ticket)

  • Server-side spec→session join table — localStorage only.
  • Breakdown toolbar button (wsplit-4).
  • Pruning stale localStorage keys on session delete (best-effort follow-up).

🤖 Generated with Claude Code

Closes #1119. ## Summary Wires `MarkdownAssistantDrawer` into `/specs/$specName` so operators can iterate on a spec body without leaving the editor. Conversation history is remembered per spec in `localStorage`. - **Toolbar + URL state.** New `Sparkles` "Assistant" button in the spec header opens the drawer. State mirrors to `?assistant=open` so deep links round-trip. `⌘⇧A` toggles via `useSpecsAssistantShortcut`; the binding is catalogued under a new `specs` shortcut scope (header shows the chord, help overlay lists it). - **Per-spec architect memory.** First successful chat response writes `last_session_id` to `localStorage` under `specs:<repo>:<specName>:lastSession`. Reopening the drawer feeds the id back via the new `initialSessionId` / `onSessionChange` props on `MarkdownAssistantDrawer`, which calls `fetchArchitectSession` and shows the prior transcript. **New chat** clears the key. Stale ids (server purged) fall back to a fresh chat silently. - **Prompt shape.** `buildSpecAssistantUserMessage` mirrors the skill-assistant prompt: filename header + `Repo:` line + fenced current body + operator request. - **Apply path.** `onApply(next)` overwrites the editor draft via a new `externalReplace={content,rev}` hatch on `SpecEditor` (no remount — autosave then persists the new body). When the draft is dirty, a Base UI Dialog confirms before clobbering. ## Test plan - [x] `bun run test src/routes/specs.test.tsx` — 24 tests pass (11 new + 13 retained from wsplit-2). - [x] `just qa` — typecheck + Biome + paraglide + i18n + sql-layer + flow-schema all clean. Web/server unit suites pass. - [ ] Manual: open `/specs/some-spec?assistant=open`, send a message, reload — transcript restores. `⌘⇧A` toggles. Dirty the editor, click Insert — confirm dialog appears. ## Out of scope (deferred per ticket) - Server-side spec→session join table — localStorage only. - Breakdown toolbar button (wsplit-4). - Pruning stale localStorage keys on session delete (best-effort follow-up). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(web): architect-backed AI drawer on /specs/$specName
All checks were successful
qa / sql-layer-check (pull_request) Successful in 11s
qa / dockerfile (pull_request) Successful in 11s
qa / i18n-string-check (pull_request) Successful in 11s
qa / db-schema (pull_request) Successful in 13s
qa / qa-1 (pull_request) Successful in 2m31s
qa / qa (pull_request) Successful in 0s
1619beeb7b
Wires `MarkdownAssistantDrawer` into the spec editor route so operators
can iterate on a spec body inline. New behaviour (#1119):

- Toolbar `Sparkles` "Assistant" button + `?assistant=open` URL search
  param so the drawer state is sharable. ⌘⇧A toggles via
  `useSpecsAssistantShortcut`, with a hint in the drawer header and a
  catalog entry under the new `specs` shortcut scope.
- Per-spec architect-session memory persisted to
  `specs:<repo>:<specName>:lastSession` in localStorage. The drawer now
  accepts `initialSessionId` + `onSessionChange` so parents can hydrate
  the prior transcript and clear the key on "New chat". Stale ids fall
  back to a fresh chat silently.
- `buildSpecAssistantUserMessage` mirrors the skill-assistant prompt
  shape: filename header + fenced current body + operator request.
- `onApply` overwrites the editor draft; if the editor is dirty, a Base
  UI Dialog confirms before clobbering. `SpecEditor` learned an
  `externalReplace={content,rev}` hatch so the route can push replies in
  without remounting the editor.

Tests: 11 new cases in `routes/specs.test.tsx` covering toolbar +
URL-param open paths, session hydration / persistence, New-chat
clearing, clean apply, dirty-confirm dialog (cancel + confirm), and the
⌘⇧A chord.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
reviewer approved these changes 2026-05-13 14:44:08 +00:00
reviewer left a comment

APPROVED

All AC from #1119 verified. CI green. One trivial dead-key note below, not a blocker.


AC verification

Criterion Status
Toolbar Sparkles button opens MarkdownAssistantDrawer
?assistant=open param round-trips; toggles call navigate({ replace: true })
buildMessage wraps persisted body in fenced block + filename header + user text
onApply on dirty draft gates behind Base UI Dialog confirm
Drawer uses <Drawer> primitive (right-side slide-out pattern)
First chat response writes specs:<repo>:<specName>:lastSession to localStorage
Reopening with stored session id passes it as initialSessionId → hydrates transcript
"New chat" clears localStorage key via onSessionChange(null)
Stale/purged session id falls back to fresh chat silently (retry: false + error effect)
Toolbar button: aria-label + visible label (both from m.specs_assistant_open())
Cmd+Shift+A registered in SHORTCUTS under specs scope; kbd hint in drawer header
validateSearch normalises non-"open" values to undefined
11 new tests: open paths, session persistence, dirty-draft dialog, keyboard shortcut

Minor observation — dead i18n key (non-blocking)

specs_assistant_subtitle was added to both en.json and fr.json but is never called. The drawer subtitle is built as a hardcoded JSX ReactNode in the assistantSubtitle memo:

const assistantSubtitle = useMemo(
  () => (
    <span className="flex items-center gap-2">
      <span>specs/{specName}.md</span>
      <kbd ...>⌘⇧A</kbd>
    </span>
  ),
  [specName],
);

The subtitle prop was correctly widened to ReactNode to accommodate this. The orphaned key should be removed from both message files in a quick follow-up (or the JSX could call the key if desired — the current approach using JSX for the <kbd> element is fine either way). Not blocking merge.

## APPROVED ✅ All AC from #1119 verified. CI green. One trivial dead-key note below, not a blocker. --- ### AC verification | Criterion | Status | |---|---| | Toolbar `Sparkles` button opens `MarkdownAssistantDrawer` | ✅ | | `?assistant=open` param round-trips; toggles call `navigate({ replace: true })` | ✅ | | `buildMessage` wraps persisted body in fenced block + filename header + user text | ✅ | | `onApply` on dirty draft gates behind Base UI Dialog confirm | ✅ | | Drawer uses `<Drawer>` primitive (right-side slide-out pattern) | ✅ | | First chat response writes `specs:<repo>:<specName>:lastSession` to localStorage | ✅ | | Reopening with stored session id passes it as `initialSessionId` → hydrates transcript | ✅ | | "New chat" clears localStorage key via `onSessionChange(null)` | ✅ | | Stale/purged session id falls back to fresh chat silently (`retry: false` + error effect) | ✅ | | Toolbar button: `aria-label` + visible label (both from `m.specs_assistant_open()`) | ✅ | | `Cmd+Shift+A` registered in `SHORTCUTS` under `specs` scope; kbd hint in drawer header | ✅ | | `validateSearch` normalises non-`"open"` values to `undefined` | ✅ | | 11 new tests: open paths, session persistence, dirty-draft dialog, keyboard shortcut | ✅ | --- ### Minor observation — dead i18n key (non-blocking) `specs_assistant_subtitle` was added to both `en.json` and `fr.json` but is never called. The drawer subtitle is built as a hardcoded JSX `ReactNode` in the `assistantSubtitle` memo: ```tsx const assistantSubtitle = useMemo( () => ( <span className="flex items-center gap-2"> <span>specs/{specName}.md</span> <kbd ...>⌘⇧A</kbd> </span> ), [specName], ); ``` The `subtitle` prop was correctly widened to `ReactNode` to accommodate this. The orphaned key should be removed from both message files in a quick follow-up (or the JSX could call the key if desired — the current approach using JSX for the `<kbd>` element is fine either way). Not blocking merge.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/agent-hooks!1127
No description provided.