Files
clide/.pql/changelog/tickets/2026-06.sql
T
jpmschweitzerandClaude Fable 5 7f16016bc3 split lib/app.dart into lib/src/shell/ (T-394)
app.dart was 1187 LOC mixing five concerns. It now keeps ClideApp +
the WidgetsApp root (~60 LOC); the shell moved to lib/src/shell/:

- root_shell.dart — keyboard/intent routing (keymap resolution,
  double-tap modifiers, menu mnemonics), the overlay stack, and the
  welcome overlay
- hat_bar.dart + project_switcher.dart — the window-chrome bar and
  its recents/file-actions dropdown (now in src/shell, not builtin/ —
  they're app chrome, not extension-shaped contributions)
- slot_host.dart — slot mounting, focus-scope integration, the
  per-slot bodies incl. the workspace split + editor drag handle;
  _SlotBody's static title resolver became the shared resolveTabTitle
- layout.dart — the three-column grid, status bar, collapse toggles,
  bottom icon rails

app.dart re-exports RootLayout, SlotHost, StatusbarHost, and
StatusbarCollapseToggle, so every existing import (incl. the three
app-level test files) is unchanged. Pure move + minimal publics
(RootShell, HatBar, ProjectSwitcherButton); full suite green with no
test edits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:29:46 +02:00

4302 lines
1.0 MiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7MFNNCC0MMY2TCNRM', 'bug', '06FB0TNQM7W0NPAEG3KWNFY9DR', 'Markdown reader 404s on absolute paths handed to files.read', 'The sidebar markdown reader (and any files.read caller) was handed an absolute path (e.g. the Claude Config tab publishing a skill''s absolute SKILL.md manifestPath). resolveUnderRoot joined the absolute path onto the workspace root (/repo + /repo/x = /repo/repo/x), which does not exist, so the reader showed ''file not found'' for a file that exists. Fix: resolveUnderRoot normalizes an absolute input as-is and relies on the existing containment check (so absolute-under-root resolves, absolute-outside still throws PathOutsideRoot). Found live opening .claude/skills/frame0-wireframe/SKILL.md.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 06:49:25', '2026-06-01 06:49:47', NULL, '22ea906d212d808ba7af9cb83060b195', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4M2Z9YZ0SA81E5Q08', 'story', '06FB0TNQM7W0NPAEG3KWNFY9DR', 'Reader can''t open user-scope Claude config files (~/.claude skills/agents/commands)', 'Clicking a user-scope skill in the Claude Config tab (e.g. /home/<user>/.claude/skills/peon-ping-toggle/SKILL.md) shows ''path outside workspace''. The markdown reader loads via files.read, which is workspace-confined for security (T-102); but user-scope Claude config files legitimately live under ~/.claude, outside the repo. Per D-76, Claude''s config surface (skills/agents/commands under ~/.claude AND the repo .claude) is clide-managed and safe to surface. Proposed: a scoped trusted-read — a new IPC verb (e.g. claude.read-config-file) that permits reads ONLY under the resolved Claude config roots (~/.claude + <repo>/.claude), still rejecting arbitrary paths. Route the Config tab''s skill/agent/command markdown clicks through it, or have the markdown reader fall back to it when the path is under a Claude config root but outside the workspace. Alternative: the Config tab already reads SKILL.md during scan (claude_config.dart) and could publish content directly via a load-by-content channel rather than a path (the reader currently only reads by path). Repo-scope skills already work after T-194; this covers the user-scope half.', 'done', 'medium', NULL, NULL, NULL, '2026-06-01 06:56:05', '2026-06-01 07:08:16', NULL, '12a1aa9958f3b375c19a5c03f694fc00', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6NTYAB8K6T50F8W00', 'bug', '06FB0TNQM6S7FDAXQX32JNCSFG', 'Decision reader: first click switches tab but doesn''t load (lost on tab mount)', 'Clicking a D-record activates the decisions.detail context-panel tab but does not load the record; only the SECOND click (any record) works. Root cause: DecisionDetailView subscribes to the ''selection'' MessageBus channel in didChangeDependencies, which runs only AFTER the extension''s activateTab mounts the view. The bus is broadcast (no replay), so the ''selection'' message that triggered the reveal is already gone — the view subscribes too late and misses it. The markdown extension already solved this: activateTab, then addPostFrameCallback -> re-publish on a separate ''load'' channel the viewer subscribes to, so the load fires after the viewer mounts+subscribes. Fix: mirror that — DecisionDetailView subscribes to ''load''; DecisionsExtension forwards ''selection'' -> post-frame ''load'' after activateTab.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 08:58:10', '2026-06-01 10:45:14', NULL, '818668b41eab83cdb411ccd17195c5c1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6HQXNNF05GXWK7BWM', 'task', '06FB0TNQM6S7FDAXQX32JNCSFG', 'Reader pin UX: pin icon, toggle, left-toggle/right-navigate', 'Polish on the reader action bar (follows T-190). (1) push-pin glyph instead of the chain/link icon; (2) the pin button TOGGLES pinned state (tap to pin current, tap again to unpin) rather than set/replace; (3) layout split — pin/unpin toggle on the LEFT, jump-to-pin moved into the navigator (back/forward) on the right. Added ReaderNav.togglePin(), PhosphorIcons.pushPin (0xe3e2) + arrowUUpLeft (0xe08a), and an active state on the action button.', 'done', 'low', NULL, NULL, NULL, '2026-06-01 11:11:32', '2026-06-01 11:31:39', NULL, 'aef79fd9dbfa2f9197f450552ce72377', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM68APBCQTMGJAZ2B9C', 'task', '06FB0TNQM6S7FDAXQX32JNCSFG', 'Tickets detail: adopt retained ReaderNav (back/forward + pin) + static tab', 'Bring the tickets detail reader in line with markdown/decisions (T-196/D-81). Adopt the retained ReaderNav(''builtin.tickets'', dataKey ''id''): the nav records selections + emits ''load''; the controller loads on ''load'' (not ''selection''); the view grabs nav.current on mount and wraps in ClidePaneChrome with a ReaderActionBar (pin toggle left; back/forward + jump-to-pin right; NO edit pencil since tickets are pql records, not files). Also drop the per-click uncontribute/contribute churn in TicketsExtension (the T-188 anti-pattern) — use the static tickets.detail tab + reveal on selection. TicketDetailController loses its panels dependency (reveal moves to the extension).', 'done', 'medium', NULL, NULL, NULL, '2026-06-01 11:38:32', '2026-06-01 12:35:04', NULL, '999c432fcd16538d949eecc86bb61a05', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4RAKSX59W1HJCZ1M8', 'bug', NULL, 'Sidebar icon rail overflows (RenderFlex) when tab count exceeds width', 'Adding the Search sidebar tab (T-52) pushed the bottom icon rail past its width: ''A RenderFlex overflowed by 54 pixels'' from clide_icon_rail.dart — a fixed Row(center, max) with one _RailButton per tab. Fix: ClideIconRail centers when the icons fit and scrolls horizontally when they don''t (LayoutBuilder + SingleChildScrollView + ConstrainedBox(minWidth: maxWidth)). Robust to any tab count. (Related: T-201 will reduce tab count by merging the pql + find-in-files search surfaces.)', 'done', 'high', NULL, NULL, NULL, '2026-06-01 14:04:00', '2026-06-01 14:04:24', NULL, '5c3606e7985e8621109b0f8ca2e5cbe7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM57GY6PTG78SD3961M', 'task', '06FB0TNQM6S7FDAXQX32JNCSFG', 'Merge pql search into the find-in-files Search tab (one search surface)', 'The pql sidebar panel (ranked text search + PQL DSL query + synced markdown-file listing) and the find-in-files Search tab (content grep) are duplicate search surfaces. Consolidate into ONE Search tab with a mode switch (option B): Find (content grep, existing), Vault (pql ranked search), Query (PQL DSL), Markdown (synced md-file listing, keeps focus-highlight + live refresh). Remove the builtin.pql SIDEBAR tab; keep the pql extension''s Backlinks context-panel tab + PqlController. Implementation: SearchPanelView gains a 4-mode switcher and holds both FindInFilesController + PqlController; extract the pql body/result rows into a reusable widget (they''re private in pql_panel_view.dart today); move the md-listing focus-sync + files.changed refresh into the merged panel; delete PqlPanelView. Test/coverage note: pql_panel_view + pql_controller are currently untested (not in the coverage denominator); folding them into the tested Search panel pulls them in, so the merge must cover the Vault/Query/Markdown modes too (same dynamic that bit T-199). Also reduces sidebar tab count (helps the rail, T-200). No D-79 conflict: grep vs ranked-search remain distinct backends; this is UI consolidation only.', 'done', 'medium', NULL, NULL, NULL, '2026-06-01 14:25:59', '2026-06-01 14:45:39', NULL, '9ae4d2691295abc84d8c3489ca1e3825', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7G8QBJ2PNQ0VREN24', 'bug', NULL, 'Editor pane never reveals on editor.open (edit button, file-tree open)', 'Opening a file in the editor (the reader''s edit pencil, a non-.md file-tree click, a decision''s edit) calls editor.open and the daemon opens the buffer, but the editor pane never appears in the workspace slot above the Claude pane. Root cause: EditorExtension contributes a workspace tab (editor.active, priority 80) but has no activate() that reveals/activates it. editor.opened is emitted on the DaemonBus, but the EditorController that handles it only exists once EditorView is mounted — and nothing ever activates the editor tab to mount it. Fix: add EditorExtension.activate() subscribing to the editor.opened / editor.active-changed DaemonEvents and calling panels.activateTab(Slots.workspace, ''editor.active''). EditorView.hydrate() already pulls the active buffer on mount, so reveal-then-hydrate avoids any publish/subscribe race.
Reopened fix (2026-06-01): the original fix called panels.activateTab(Slots.workspace, ''editor.active''), but _WorkspaceSlot renders its editor split off arrangement.editorOpen, NOT the active tab — so the editor never appeared. Real fix: EditorExtension.activate now calls arrangement.openEditor() on editor.opened / active-changed(non-null) and closeEditor() on active-changed(null). Test asserts arrangement.editorOpen.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 08:58:19', '2026-06-01 16:36:38', NULL, '7c752b0b9c4b83d17bcf896dfc6b15e4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5A3984KFPD4NKRG6M', 'bug', NULL, 'rate_limit_event with numeric resetsAt crashes the Claude session', 'stream_json_session._statusFromEvent cast info[''resetsAt''] as String?, but claude sends resetsAt as a unix-epoch NUMBER → ''int is not a subtype of String?'' unhandled in _onLine, killing the session''s line parsing. Fix: accept num (epoch seconds → DateTime.fromMillisecondsSinceEpoch) or String (ISO). Surfaced in a live session log.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 16:48:31', '2026-06-01 16:48:56', NULL, '1748dedeac6b6e160d609c3336488687', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM643F7Z648KNFPT7GR', 'bug', '06FB0TNQM7G8QBJ2PNQ0VREN24', 'Editor split throws semantics + SelectableRegion exceptions on open', 'Opening the editor split (now that T-197 works) flooded two exceptions. (1) The _EditorDragHandle Semantics had slider:true + value + onIncrease/onDecrease but no increasedValue/decreasedValue → ''SemanticsNode with action increase needs both value and increasedValue'' on every flush; added increasedValue/decreasedValue. (2) Reparenting the Claude pane (direct child → Column/Expanded) when the split opens disposed its ClideSelectionArea/SelectableRegion subtree mid selection-update microtask → ''selectable not in this registrar'' + ''renderObject of inactive element''; fixed with a stable GlobalKey on the workspace primary so Flutter moves the element instead of disposing it.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 16:48:31', '2026-06-01 16:48:56', NULL, '1c5aeeaaf738cf15cbb4b1ef3b2f57fe', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM65R8CNFNE1MA5GGR0', 'task', '06FB0TNQM5V7GHH42RHV04DPTR', 'Fix dead default keymap: focus.next/previous undefined drops whole preset', 'The keymap loader catches FormatException from preset parsing and silently sets _preset=null (keymap_service.dart load()). default.yaml binds tab->focus.next and shift+tab->focus.previous, but neither id exists in builtinIntents (only focus.nextPanel/previousPanel). parseIntentId returns null -> KeymapLayer.fromYaml throws -> the ENTIRE default preset is dropped at boot. So palette (ctrl+shift+p), quick-open (ctrl+p), find-in-files, and text-scale bindings are all dead at runtime. Uncaught because every keymap_service_test injects a synthetic bundle; the real asset is never parsed. Fix: add focus.next->NextFocusIntent and focus.previous->PreviousFocusIntent (Flutter-provided, like activate/dismiss) to builtinIntents so Tab does correct widget focus traversal; add a test that loads the REAL assets/keymaps/default.yaml and asserts it parses with the expected binding count, as a regression guard for every shipped preset.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 18:48:19', '2026-06-01 18:51:08', NULL, '4c8a15d72af7462d3c444373e7e1bdcb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6M83TTXHRXESFZY14', 'story', '06FB0TNQM5V7GHH42RHV04DPTR', 'Vim mode service + status-bar mode indicator', 'A small mode-tracking service (ChangeNotifier) that owns the current Vim mode (normal/insert/visual), publishes vim.normal / vim.insert / vim.visual scope flags via KeymapService.setScopeFlag, and exposes the mode to the editor (T-206) and a status-bar indicator (-- NORMAL -- / -- INSERT -- / -- VISUAL --). Handles transitions: Esc -> normal (from any mode); i/a/o/I/A -> insert (from normal); v -> visual (from normal); colon -> command-line indicator (stretch). Resets to normal on editor focus by default (configurable). The when-clause grammar already supports dotted flags (vim.normal && editor.focused), confirmed in when_clause.dart. Service is a pure ChangeNotifier with no deps; fits in lib/kernel/src/keymap/ or as an editor builtin piece. Acceptance: setPreset(''vim'') + this service produce correct mode transitions; a regression test asserts i->insert and Esc->normal flip the scope flags; the status bar reflects mode.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 18:48:47', '2026-06-01 18:59:19', NULL, 'e0e01b13b1d2cc833f4f1ef79aa1663e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4PH1J6ENYT4PJZRHG', 'story', '06FB0TNQM5V7GHH42RHV04DPTR', 'Key-sequence resolution in the keymap layer (Vim motions, counts)', 'The resolver is single-chord: KeyChord.fromKeyEvent -> Keymap.resolve fires and forgets one keystroke. Real Vim needs multi-key sequences (dd, gg, dw, ciw, yy) and count prefixes (5j, 3dd). Add a pending-sequence model to KeymapService: accumulate chords into a buffer, match against sequence bindings by prefix, fire on full match, reset on no-prefix-match or timeout. Add a KeySequence type (ordered list of KeyChord) alongside KeyChord. Extend the YAML schema to express sequences distinctly from alias-lists: keys:[d,d] today means ''either d or d'' (alias) — need a ''sequence:'' key or a chord-string notation (e.g. ''d d'' space-separated) for ordered sequences. Add count-prefix capture so a leading digit run is parsed as a repeat count passed to the intent. Keep single-chord resolution unchanged (fast path). This also unblocks JetBrains shift+shift (T-66) via a double-tap special-case. Decision-worthy: the sequence/notation choice may warrant a D-record.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 18:48:27', '2026-06-01 19:12:01', NULL, 'b615f886c304b96668db19a2a295cbd2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5REFZXMY8ESWN0Z90', 'story', '06FB0TNQM5V7GHH42RHV04DPTR', 'Modal editor: Vim motion & edit intents', 'The editor (lib/builtin/editor/src/editor_view.dart) is a raw Flutter EditableText that only handles Cmd/Ctrl+S; in normal mode, letter keys must NOT insert text and must instead drive motions/edits. Add typed editor Intents + Action handlers wired into the editor that operate on the SyntaxTextController/TextEditingValue: cursor motions (cursorLeft/Down/Up/Right for h/j/k/l), word motions (wordForward/wordBackward/wordEnd for w/b/e), line motions (lineStart/lineEnd/firstNonBlank for 0/$/^), document motions (documentStart/End for gg/G), edits (deleteChar x, deleteLine dd, deleteWord dw, changeLine cc, changeWord cw, yankLine yy, paste p/P, openLineBelow/Above o/O), and insert-entry (enterInsert i/a/I/A/o). Motions+edits must compose with the count prefix (T-205) and operator+motion (dw, cw). The editor must read the active Vim mode (T-206 service) to decide whether a bare key inserts or commands — key interception must win over EditableText''s text input in normal mode. Acceptance: each intent has an Action that mutates the buffer correctly; regression tests cover j (cursor down), x (delete char), dd (delete line), i (enter insert).', 'done', 'high', NULL, NULL, NULL, '2026-06-01 18:48:40', '2026-06-01 19:27:18', NULL, '44c8001114ba7c63d5d2ab6ba13223a6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM48QJV4N7J62436GC0', 'task', '06FB0TNQM4QYYKHPQ03H3QD61W', 'Regression test: clideVersion matches pubspec version', 'Gap 6 version-drift guard. clideVersion in lib/src/build_info.g.dart (generated by gen-build-info) currently matches pubspec.yaml `version:` (both 2.1.0). Add a test asserting they stay equal so an agent keying off `clide version` is never misled.', 'done', 'low', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 09:00:37', NULL, '1a10d96abef4a38149f02ec60b4a2d5d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4C2PSN2GVJY9JDSZG', 'task', '06FB0TNQM4QYYKHPQ03H3QD61W', 'Build the C clide client by default', 'Make the `clide-cli` target (Makefile:284) a dependency of `build` (Makefile:169) and `install` (Makefile:195) so the C client is always produced. Today it builds only via the explicit, non-default `make clide-cli`. Acceptance: a plain `make build` / `make install` produces the C client binary for the current OS.', 'done', 'high', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 09:00:37', NULL, '8d614f61e2893184e1e6792acf92c64b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7171NRB1T82TGP5RG', 'task', '06FB0TNQM4QYYKHPQ03H3QD61W', 'install delivers the C client to PATH on macOS and Linux', 'Fix `make install` so $(INSTALL_DIR)/clide (default ~/.local/bin/clide) IS the compiled C client, not the Flutter GUI binary. Today Makefile:200 symlinks the GUI bundle runner on both platforms. Acceptance: after `make install`, `which clide` resolves and `clide ping` returns {"pong":true} against a running instance on macOS and Linux. Blocked by ''Build the C client by default''.', 'done', 'high', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 09:00:37', NULL, 'a1978f7f0fec1c0b0c0bd4b7d1f8f65e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM54WTNQDMYT0AVR4WM', 'task', '06FB0TNQM7XMQ44AVCAARQZX14', 'Decide the dogfood agent model (governance Q/D record)', 'Gap 5 from self-analysis.md — foundational. Two distinct ''Claude inside clide'' stories with different gaps: (A) the clide-HOSTED stream-json session (D-77/D-78) — the user''s primary Claude pane, which itself needs `clide` on PATH to drive the surrounding IDE; (B) an EXTERNAL agent (e.g. a Claude Code harness, TERM_PROGRAM=zed as observed) driving via `clide ...` IPC, which clide cannot fully observe because plain file reads / `make test` / `git` bypass clide. Decide the intended model — hosted, external, or both — and record it under governance/ (claim via `pql decisions claim`). Gates how Epic B bootstraps and how Epic C frames observability.
Resolved (2026-06-03): recorded as D-83. Decision — clide commits to BOTH agent models with the clide-HOSTED stream-json session (D-77/D-78) as the PRIMARY dogfood target (the one clide spawns, so the one Epic B/T-214 bootstraps: CLIDE_SOCK/CLIDE_WORKSPACE + PATH + context note + Bash(clide *) allow rule), and the EXTERNAL CLI driver (D-68) as a first-class but SECONDARY, best-effort integration (manual install via T-212; no promise to observe its non-clide tool use). Epic C/T-218 parity is scoped to clide''s own surfaces reflected through the CLI in both directions; an external agent''s side-channel reads/tests/git are out of parity scope.', 'done', 'high', NULL, NULL, 'D-77', '2026-06-02 18:13:52', '2026-06-03 09:05:50', NULL, 'f7b7cda4651e06371d642fe86313466a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM51CNEJP52P79RDVJW', 'task', '06FB0TNQM78J2H2S0R79SSSPW8', 'Inject an agent context note describing the CLI surface + parity contract', 'Give the hosted agent a short note (CLAUDE.md fragment / system reminder) stating it is inside clide and how to drive the editor / git / file tree / panes via `clide ...`, plus the D-6 parity contract. Acceptance: a hosted agent is told, without the user having to explain it.', 'done', 'medium', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 09:18:28', NULL, '1a21a4b790c9cbb2c45c26a87688ec22', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7WYF8ESBSGAQF1S48', 'task', '06FB0TNQM78J2H2S0R79SSSPW8', 'Pre-seed a Bash(clide *) permission allow rule for hosted agents', 'So the hosted agent is not prompted on every `clide ...` call. Acceptance: clide-hosted sessions get a Bash(clide *) allow rule in their effective settings.', 'done', 'medium', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 09:18:28', NULL, '5426b5365b2d54a9ad55cc5338f748c6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4TP6KXTB1GHKBW1G4', 'task', '06FB0TNQM78J2H2S0R79SSSPW8', 'Export CLIDE_SOCK/CLIDE_WORKSPACE + ensure clide on the child PATH', 'When clide spawns/hosts an agent (the stream-json session per D-77, and any launched agent), inject CLIDE_SOCK (the per-workspace deterministic socket, D-70) and CLIDE_WORKSPACE into the child env, and guarantee `clide` is on the child PATH. Acceptance: a hosted agent''s shell has both vars and `clide ping` works with zero manual socket discovery. Blocked by Epic A delivery.', 'done', 'high', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 09:18:28', NULL, 'fa6e599cfdd6165043ab54427f16d298', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM78J2H2S0R79SSSPW8', 'epic', '06FB0TNQM7XMQ44AVCAARQZX14', 'Bootstrap the clide-hosted/launched agent', 'Gap 2 from self-analysis.md. Even with the binary installed, a fresh session has no signal it is hosted by clide. There IS an MCP discovery lock (~/.claude/ide/<pid>.lock pointing at workspace + SSE URL) but NOTHING for the Bash/CLI path: no CLIDE_SOCK/CLIDE_WORKSPACE env var, no injected context note, no pre-seeded Bash(clide *) allow rule. Discovery is the difference between a capability existing and getting used — the probing agent had to be told ''you''re inside clide'' and then hunt for the socket.', 'done', 'high', NULL, NULL, 'D-6', '2026-06-02 18:13:52', '2026-06-03 09:18:38', NULL, 'f6131db7ead26edd61619c18cc816e72', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7XZQJAK31WRVQ0HGW', 'bug', '06FB0TNQM68T660K07YH3X5DH0', 'Composer draft lost when the Claude pane UI changes', 'Text typed into the Claude composer is lost whenever the pane UI changes underneath it. The clearest trigger: a permission prompt / AskUserQuestion arrives and the interaction zone replaces the composer; when it resolves and the composer returns, the half-typed message is gone. It can also drop on any pane rebuild/refresh (new transcript message, status change, tab re-render) if the draft lives only in ephemeral widget State.
Make the draft persistent: an unsent composer message must survive UI changes and come back intact.
Likely cause: the composer''s TextEditingController lives in widget State that is disposed/recreated when the pane rebuilds or the interaction zone swaps the composer out. The draft (text + selection) needs to be hoisted somewhere that outlives the widget.
Acceptance:
1. Type in the composer, trigger a permission prompt (composer replaced by the interaction zone), resolve it -> the composer returns with the typed text and caret intact.
2. The draft survives an arbitrary Claude-pane rebuild (incoming transcript message, status update, sub-tab switch) without loss.
3. Draft is per-session: switching between Claude sessions/panes preserves each session''s own unsent text; sending or explicitly clearing empties it.
4. Selection/caret position preserved, not just the string (nice-to-have).
Implementation sketch: store the draft (text + selection) on the per-session model/controller (kernel- or session-scoped), not in the composer widget''s State. Seed the TextEditingController from it on mount and write back on change, so a remount after the interaction zone closes restores it. Relates to T-227 (focusing the composer) and the interaction-zone behavior where prompts replace the composer rather than rendering inline.', 'done', 'high', NULL, NULL, NULL, '2026-06-03 09:49:35', '2026-06-03 10:02:02', NULL, '63ae2f059c866728f67f7009b420657b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7SA9TEX5CNMTG67MW', 'task', '06FB0TNQM68T660K07YH3X5DH0', 'Composer prompt history via Up/Down (Claude-CLI-style)', 'Arrow Up/Down in the Claude composer should scroll through previously-sent prompts, matching the Claude Code CLI UX:
- Up: if the caret isn''t on the first visual line, move up one line (normal editing); once on the first line, Up steps to the previous prompt in history. Down mirrors it (move down within multiline; at the last line, step to the next/newer entry).
- When entering history, stash the current draft as the newest entry so Down past the most recent restores exactly what the user was typing.
- History is the sequence of prompts submitted from this composer (this session at least; persisting across restarts is a nice-to-have).
- Plays well with the existing Enter-submit / Shift+Enter-newline and the slash typeahead (Up/Down already drive the typeahead when it''s open — history nav only applies when the typeahead is closed).
Implementation: a small history controller + caret-line detection in ClaudeComposer (EditableText); the Up/Down handling lives alongside the existing _onKey/Shortcuts. Acceptance: send a few prompts, Up walks back through them, Down returns through them to the restored draft; multiline prompts navigate line-by-line before crossing into history; widget tests for the bound conditions (first/last line) and draft stash/restore.', 'done', 'medium', NULL, NULL, NULL, '2026-05-24 09:42:44', '2026-06-03 10:05:55', NULL, '4d0babaecb6b5cb3f77222e9ed786f3b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5PFAYE4XH7WV6VDW8', 'story', '06FB0TNQM68T660K07YH3X5DH0', 'Click empty Claude-pane area to focus the composer', 'Clicking anywhere in the Claude pane that is NOT an interactive control (copy button, approve/reject, AskUserQuestion options, links, selectable transcript text, scrollbars, tabs) should move keyboard focus to the composer text field at the bottom. Today focusing the composer requires precisely clicking the field; a click on the conversation background does nothing, so the input is fiddly to focus.
Goal: make the composer easy to focus — a click on dead space in the pane lands the cursor in the input, like clicking an empty area of a chat app focuses its reply box.
Acceptance:
1. A tap on non-interactive Claude-pane background focuses the composer input (cursor ready to type).
2. Taps on actual controls keep their behavior (copy, approve/reject, AskUserQuestion choices, tab strip, links) — the focus-grab must not swallow or pre-empt their gestures.
3. Text selection in the transcript still works: a click-drag to select must NOT steal focus to the composer (only a plain tap/click-release with no selection focuses it).
4. Interaction-zone nuance (D-78): when a permission / AskUserQuestion prompt has replaced the composer in the interaction zone, a background click should focus that prompt''s primary control instead of the (absent) composer — or do nothing — never resurrect the composer.
Implementation sketch: wrap the pane body in a tap recognizer (behavior: translucent / deferToChild) that, on a tap that did not hit a child gesture and produced no text selection, requests focus on the composer FocusNode. Mind the SelectableRegion so selection drags aren''t hijacked.', 'done', 'medium', NULL, NULL, NULL, '2026-06-03 09:48:23', '2026-06-03 10:09:26', NULL, '68310a50972a774abfeadd32f221a140', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM68T660K07YH3X5DH0', 'epic', NULL, 'Claude composer ergonomics: focus, persistence, and history', 'Make the Claude composer (the prompt input at the bottom of the Claude pane) feel effortless to use. Groups the input-box ergonomics work:
- T-227 — click empty pane area to focus the composer
- T-228 — preserve the in-progress draft across UI changes (permission prompts etc.)
- T-163 — Up/Down arrow recall of prompt history (Claude-CLI-style), triggered when the caret hits the top/bottom edge of the field
Theme: the composer should be easy to focus, never lose what you typed, and let you recall and re-send prior prompts the way the Claude CLI does. These are independent leaves that share the composer surface and per-session draft/history state, so they''re worth coordinating.', 'done', 'medium', NULL, NULL, NULL, '2026-06-03 09:51:28', '2026-06-03 10:09:26', NULL, 'ec5d2dcaf4e33b2d4dde1b3d4623110a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6Y5SET5WGSS2W78F8', 'task', '06FB0TNQM7AMEYEBTF8YGJ4BVG', 'Register built-in panes into the PaneRegistry the CLI reads', 'Make the kernel layout''s TabContribution panes (Claude / Files / Editor and any viewer) visible to `pane list` / `pane focus` by registering or mirroring them into PaneRegistry (lib/src/panes/registry.dart, read by pane_commands.dart:121). Acceptance: with the GUI open, `clide pane list` enumerates the live panes the user sees, with stable ids and focus state.', 'done', 'high', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 10:31:10', NULL, '697f1f648cd46893ac5863c139ba4bee', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6EGSD0SB7NYNSP1BM', 'task', '06FB0TNQM7AMEYEBTF8YGJ4BVG', 'Reflect active editor buffer + focused file + selection into EditorRegistry', 'For UI-driven (non-IPC) opens and focus changes, mirror the active file + selection into EditorRegistry (lib/src/editor/registry.dart, read by editor_commands.dart:120/136) so `editor active` / `editor list` track what the user is actually looking at, not only IPC-opened buffers. Acceptance: focusing/opening a file in the GUI is reflected in `clide editor active`.
Resolved (2026-06-03): done by prior editor work, not new code. Verified by reading the flow: openWorkspaceFile (file_open.dart) routes non-.md opens through the editor.open IPC verb -> EditorRegistry.open -> _setActive, so file-tree/quick-open opens already populate ''editor active''/''editor list''; tab switches go through editor.activate; and editor_view._onTextChanged pushes editor.set-content on caret-only moves too (its guard skips only when BOTH text and selection are unchanged), so selection is reflected. The probe saw editor active=null only because no code file was open (just the Claude pane / a markdown reader) -- correct, not a gap. The genuine remainder -- the read-only markdown/decisions READER''s currently-viewed file (routed via the D-81 ReaderNav bus, not editor.open, and rightly NOT an EditorRegistry buffer) -- is folded into T-221 (clide status: focused file + selection). Editor-reflects-opens claim is code-read, to be confirmed on the next live run.', 'done', 'high', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 10:59:25', NULL, '178db75de081136eebacc44488a52397', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM67ET3VQP90WRXVSMG', 'task', '06FB0TNQM5HM3N07VB3RPFJCFM', 'Per-agent permission-mode cycle badge in the cockpit roster', 'Per-agent permission-mode control in the cockpit roster (extends T-171). Each roster row shows a mode badge (D default / A acceptEdits / P plan). Click cycles the SAFE trio default -> acceptEdits -> plan and sends set_permission_mode to that agent session (a confirmed stream-json control subtype). bypassPermissions is a footgun, so it is reachable only on SHIFT-click (behind a confirm), and the tooltip documents both behaviours. Acceptance: clicking the badge cycles the safe modes and the session receives set_permission_mode; shift-click can reach bypass behind a confirm; the badge reflects the live mode (T-157 status); tooltip explains click vs shift-click; widget + transport tests. Wireframe: docs/design/wireframes/claude-prompts/05-team-cockpit-sidebar.png. Blocked by T-169 (orchestrator).
Verified done (2026-06-03): the cockpit per-agent permission-mode cycle badge is implemented and wired in claude_meta_sidebar.dart — _PermissionModeBadge (safe cycle default->acceptEdits->plan, Shift-click bypass behind a confirm, live mode from SessionStatus). No further work; closing. The primary-pane equivalent is T-226.', 'done', 'low', NULL, NULL, 'D-77', '2026-05-25 09:16:05', '2026-06-03 11:41:17', NULL, 'ccaa859108ef4e5367f2b149ce4f3d28', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM758PXMJH16PA3JHFW', 'task', '06FB0TNQM7AMEYEBTF8YGJ4BVG', 'Add a clide status one-shot snapshot command', 'Gap 6. Add a `status` command to the dispatcher (currently unknown -> exit 3) returning a single snapshot: active pane, focused file + selection, git summary, layout — the natural first call an agent makes to orient. Acceptance: `clide status` returns a structured snapshot with exit 0. Depends on the pane/editor registries (C1/C2) for its pane and file fields.
Scope addition (2026-06-03, from T-220): clide status must surface the focused file + selection from BOTH the editor (EditorRegistry.active, already populated) AND the read-only markdown/decisions reader''s current file (D-81 ReaderNav), since viewer files rightly do not live in EditorRegistry. So ''focused file'' = whichever of {active editor buffer, active reader doc} the user is currently looking at. Pane field comes from the T-219 view-pane snapshot; git summary from git.status; layout from the arrangement.', 'done', 'medium', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 11:50:02', NULL, '9b5887b0dba408afb7721e957cc8c483', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7AMEYEBTF8YGJ4BVG', 'epic', '06FB0TNQM7XMQ44AVCAARQZX14', 'Make the live workspace observable to the CLI (D-6 parity)', 'Gap 3 from self-analysis.md, verified in code. PaneRegistry (lib/src/panes/registry.dart) is written ONLY by the pane.spawn IPC path, so the built-in TabContribution panes — Claude (builtin/claude/src/extension.dart:42), Files (builtin/files/.../extension.dart:22), Editor (builtin/editor/.../extension.dart:52) — render in the kernel layout tree but never appear in `pane list` (read at pane_commands.dart:121). EditorRegistry is written by editor.open IPC (the Files tree uses it) but the active Claude pane / focused file / selection are not reflected. Result while the UI is in use: `clide pane list` -> {"panes":[]}, `clide editor active` -> {"active":null}. The ''agent sees what the user sees'' half of D-6 is missing.', 'done', 'high', NULL, NULL, 'D-6', '2026-06-02 18:13:52', '2026-06-03 11:50:08', NULL, 'e7c51439a2c4e6f291284cf6add77602', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6GJRWZVHZF24TPPS4', 'task', NULL, 'Shift+Tab work-mode cycler on the focused Claude pane', 'Gap found while dogfooding (2026-06-03): the Claude pane shows the permission mode in its status line (permissionModeLabel, claude_status.dart:44) but has NO quick way to CHANGE it, unlike the CLI where Shift+Tab cycles work modes (plan / read / edit / yolo == plan, default, acceptEdits, bypassPermissions). Today the only interactive setter is the team meta-sidebar per-agent control (claude_meta_sidebar.dart:342); the primary pane has none. There is a command claude.agent.set-permission-mode <sessionId> <mode> (extension.dart:148) but it needs an explicit id + explicit mode, and there is no ''cycle'' verb. setPermissionMode() over the stream-json control channel already works (stream_json_session.dart:671, D-78). Scope: (1) add a mode-cycle action that targets the focused/primary session and advances the safe trio default -> acceptEdits -> plan -> default (bypassPermissions only via a confirmed/explicit path, per the T-181 footgun guard); (2) bind it to Shift+Tab INTERCEPTED at the focused Claude composer -- note shift+tab is globally focus.previous (default.yaml:31), so this needs consumer-level interception like the Vim editor (D-82), falling back to focus traversal elsewhere; (3) make the status-line mode label a clickable badge that cycles on click (the ''cockpit badge'' the stream_json_session comment already promises at line 668-669). Relates to T-181, D-77/D-78, D-82.
Refinement (2026-06-03): Shift+Tab is REJECTED as the trigger — Tab/Shift+Tab are real a11y focus-traversal intents (focus.next/focus.previous) since T-204, so hijacking Shift+Tab would break keyboard navigation. Instead: (a) Ctrl/Cmd+M cycles, intercepted at the focused Claude composer (so it targets that pane''s session, no global ''find focused session'' needed); (b) the status-line mode label becomes a FOCUSABLE button — cycles on click and on Enter/Space when focused (a11y-native, Tab reaches it); (c) a ''Claude: Cycle permission mode'' palette command. Safe trio default->acceptEdits->plan->default; bypassPermissions only via explicit confirmed path.', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-03 09:37:54', '2026-06-03 11:50:23', NULL, 'ac8c4672be7aa07a7ade499da4acd9a3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM427BVZYN6MD7ZZX74', 'task', '06FB0TNQM7XMQ44AVCAARQZX14', 'CLI verb to open a doc in a UI reader (drive-half of D-6 parity)', 'Dogfood finding (2026-06-03): Epic C gave the CLI the OBSERVE half of D-6 parity (clide status / pane list read live UI state). The DRIVE half is missing: there is no CLI/IPC verb that opens a doc in a UI reader. Opening a ticket/decision/markdown doc happens only inside the Flutter app, by publishing to the kernel MessageBus (e.g. messages.publish(''builtin.tickets'',''selection'',{''id'':id}); see tickets/src/extension.dart:29 which subscribes publisher ''builtin.tickets'' channel ''selection'', and ticket_detail_view.dart). lib/src/daemon/ has NO verb that reaches the MessageBus, so an agent cannot say ''look at this with me''. Proposed: add a bridge verb (e.g. ui.open / view.open <publisher> <id-or-path>) that publishes a ''selection'' message to the bus, so ''clide ui open tickets T-48'' opens T-48 in the ticket reader (and decisions/markdown analogously). Wiring: thread the kernel MessageBus into the dispatcher the same way panels/readerNav were threaded for T-219/T-221 (daemonClientFactory param or post-boot capture in main.dart). Honor D-6 (every UI action has a CLI verb). Complements Epic C (T-218): observe -> drive.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-03 13:17:32', '2026-06-03 13:30:05', NULL, 'f26d2dca0dc0d4d3c7b107347e2f22a5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5BJCK5YG3R0NEEF70', 'task', '06FB0TNQM7XMQ44AVCAARQZX14', 'CLI argv args dont reach typed command handlers (positional/flags vs named)', 'Major dogfood finding (2026-06-03), verified live: parameterized subsystem commands are NOT reachable from the clide CLI. The C client sends raw argv; parseArgv (lib/src/cli/argv_to_request.dart) turns ''clide editor open X'' into args={positional:[X]} (also flags:{}, passthrough:[]). But the typed handlers read NAMED top-level keys: editor.open reads args[''path''] (editor_commands.dart:64), files.read reads args[''path''], pane.close/editor.activate read args[''id''], etc. Nothing maps positional/flags -> those names, so every arg-taking verb returns ''X is required'' from the CLI. Confirmed: ''clide editor open pubspec.yaml'' and ''--path=pubspec.yaml'' both -> ''path is required''; ''clide files read pubspec.yaml'' -> ''files.read requires a path''. grep shows ONLY the new ui_command.dart reads args[''positional'']. Net: an external agent can OBSERVE (no-arg reads: status, git status, files root, pane list, editor active) but cannot DRIVE anything parameterized -- undercutting CLI-first (D-1) and the D-6 parity premise behind the whole T-208 initiative. Needs a decision on the mapping contract: most ergonomic is a per-command POSITIONAL/flag schema declared where the handler registers (extends the co-registered schema of D-74) so ''clide editor open <path>'' binds positional[0]->path; alternative is lifting --flags into top-level named args. Then either remap in argv_dispatch/parseArgv before dispatch, or have handlers read a normalized accessor. High priority: this is the gating bug for ''Give Claude hands''.
Resolved (2026-06-03): NO new mechanism or decision needed -- the contract already existed. D-74''s CommandSchema.normalize (lib/src/ipc/command_schema.dart) already folds the argv shape {positional,flags} into named args using a declared positional ordering, and the dispatcher already runs normalize+validate. The arg-taking commands simply never registered a schema (adoption is opt-in per D-74). Fix = adopt it: added positional schemas (non-required, so missing-arg errors stay as handlers produce them; only effect is positional->named mapping + numeric coercion of line/cols/rows) to editor.open/activate/read/save/close, files.read/ls, pane.close/focus/resize/write. Handlers unchanged. Tests: CLI-shape ({positional:[...]}) dispatch now binds (editor/pane/files command tests). DEFERRED (still named-arg only; in-process UI works, CLI-arg niche): editor.insert/replace-selection/set-selection/set-content (content/selection encoding + active-buffer fallback make positional ambiguous), pane.spawn (argv is a list, not scalar positional), git.* arg verbs (agents use plain git; D-83 external-agent scope). NOTE: takes effect on app RESTART, not hot reload -- the dispatcher is built once at boot.', 'done', 'high', NULL, NULL, 'D-6', '2026-06-03 13:29:49', '2026-06-03 13:41:42', NULL, 'fc28f03686962cd26af10e60272af278', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JR6QZDMKE167C4MR', 'task', NULL, 'add Catppuccin theme to bundled themes', 'User request: ship a Catppuccin theme alongside the four bundled themes (clide, midnight, paper, terminal — D-44).
**Acceptance:**
- New theme YAML at `lib/kernel/src/theme/themes/catppuccin.yaml` (or split for the four Catppuccin flavours: latte / frappe / macchiato / mocha — pick one or all four; recommend Mocha as the dark default and Latte for the light variant).
- Tokens populated for every key in `SurfaceTokens` (lib/kernel/src/theme/tokens.dart) — chrome, panels, tabs, list items, status colours, syntax tokens (D-45).
- WCAG-AA contrast gate (D-22) passes against the theme.
- Listed in `assets/licenses.yaml` with the upstream license (MIT) and attribution.
- Theme picker (`builtin.theme-picker`) shows it in the live-switch menu.
**Source palettes:** https://github.com/catppuccin/catppuccin#-palette — official hex values, don`t paraphrase.
**Why now:** test user request; also a good exercise for any future user-contributed theme since Catppuccin is the most common one to ask for.', 'done', 'low', NULL, NULL, NULL, '2026-05-06 08:30:15', '2026-06-03 14:56:07', NULL, 'e09db083ea034b33827a1bf708bd46bd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM492DTRZJK9CGQPGDC', 'story', NULL, 'Theme-switcher popout in the status bar', 'Add a theme switcher to the far-right corner of the bottom status bar (the empty area to the right of the ''application ok'' / ipc-status item — see the user''s screenshot). Today switching themes is only reachable via the theme.pick command / palette (ThemePickerExtension opens a modal picker_view); there''s no always-visible affordance.
Add a compact status-bar control that opens a POPOUT (popover anchored to the control, not the full-screen modal) for quick theme switching:
- A small right-aligned status item — a palette/swatch icon (or the current theme name). Contribute via StatusItemContribution at Slots.statusbar with priority > 100 so it sits in the right group, to the right of the ipc/toolchain ''application ok'' item (priority 100).
- Click (or focus + Enter/Space) opens a popover listing ThemeController.available, the current (ThemeController.currentName) marked.
- Selecting an entry applies it immediately via ThemeController.select(name) and closes the popout.
- Esc / click-away dismisses with no change. Keyboard navigable (arrow + Enter), matching the palette/quick-open overlay pattern.
- Natural home: ThemePickerExtension (lib/builtin/theme_picker). Reuse the theme list + select logic from picker_view rather than duplicating; the modal theme.pick command stays as-is for parity (D-6).
Acceptance:
1. A theme control sits in the far-right of the status bar.
2. Clicking/activating it opens a popover of all available themes with the current one marked.
3. Selecting a theme applies it live (ThemeController.select) and closes the popout.
4. Esc / outside-click dismisses without changing the theme; the control is keyboard-operable.
5. theme.pick (palette/command) still works unchanged.', 'done', 'medium', NULL, NULL, NULL, '2026-06-03 13:49:44', '2026-06-03 15:04:07', NULL, '0633fbd26056b04c5e542283be91a14e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5GH6VP8TZA95NMYK0', 'story', '06FB0TNQM7W0NPAEG3KWNFY9DR', 'Cluster meta messages into a collapsible activity card', 'The Claude pane renders every transcript item as its own row, so a heavy agent turn becomes a wall of tool-call/result rows (Bash <cmd> / Bash · result {…} / ''completed with no output'') that buries the messages that matter (user + Claude prose). Wireframe: docs/design/wireframes/claude/meta-activity-card.png.
Fold runs of consecutive ''meta'' items into one live, collapsible activity card:
- The card shows the MOST RECENT meta line as a live ticker and updates in place as new ones stream in — a running ''what''s happening now''. A step count (''14 steps'') sits on the right.
- Collapsed by default; click the chevron to expand the full list of folded steps, click to re-collapse.
- ''Sticky'' items never fold — they render first-class. A sticky item SEALS the current card and a new cluster begins after it. A cluster = one unbroken run of foldable items between two sticky items.
Taxonomy over the sealed ConversationItem set (transcript_reader.dart): UserMessage, AssistantTextMessage, AssistantThinkingMessage, AssistantToolUse, ToolResultMessage.
- Always sticky: UserMessage, AssistantTextMessage. Permission/AskUserQuestion prompts already live in the interaction zone (D-78) and are inherently sticky / cluster-breaking — no change, but note they break the run too.
- Foldable: AssistantToolUse + its paired ToolResultMessage (pairing already exists via toolUseById, T-168).
Refinement decisions (2026-06-03, user):
1. WHAT FOLDS is a 3-level setting (conservative → aggressive); DEFAULT = level 1. Build the foldable predicate so all three are switchable:
- L1 (default): fold tool calls + results only; diffs (Edit/Write results) AND AssistantThinkingMessage stay first-class/sticky.
- L2: also fold thinking; diffs stay sticky.
- L3: fold everything except UserMessage + AssistantTextMessage (incl. diffs + thinking).
2. ERRORS: a failed/error ToolResultMessage SURFACES — treated as sticky, stays visible and breaks the cluster (see wireframe state B). Start here; the user noted some non-fatal errors are themselves clutter, so expect to tune which errors surface vs. fold.
3. DEFAULT/RUN STATE: always collapsed, showing the last line + step count, with the ticker updating live. NO setting. (Rejected auto-expanding the in-flight cluster: it scrolls the last useful sticky message out of view during long runs and only collapses when done — worst timing. Collapsed-with-live-ticker keeps the last sticky message anchored while preserving the motion/dynamism.)
Implementation home: a ''collapse adjacent foldable items'' grouping pass over ConversationController.items, consumed by ConversationView; the card is a new conversation_card variant. Keep it accessible (keyboard expand/collapse + semantics: announce step count and collapsed/expanded state).
Acceptance:
1. Consecutive foldable items collapse into one card; the collapsed card shows the latest line + a live-updating step count.
2. A sticky message (user / Claude prose / surfaced error / interaction-zone prompt) seals the card and starts a new cluster after it.
3. Expanding shows every folded step (tool call + result pairs) in order; collapsing returns to the one-line view.
4. The foldable level is a setting (default L1: diffs + thinking stay first-class); switching levels re-groups the transcript.
5. Failed tool results surface as their own (sticky) row and break the cluster.
6. Keyboard + screen-reader accessible (expand/collapse, step count announced).
Implemented (2026-06-03): pure grouping in activity_cluster.dart (groupConversation + FoldLevel none/tools/thinking/everything + RenderGroup StickyItem/FoldedCluster), fully unit-tested. Activity card + grouping wired into conversation_view.dart (collapsed live ticker of the latest step + step count; click/Enter expands to the folded steps in order; Semantics announces count + expanded/collapsed). Default level = L1 (tools): tool calls+results fold; user/Claude prose, FAILED results (sticky, surfaced), diffs (Edit/Write), and thinking stay first-class. Added FoldLevel.none (no folding) used by the existing item-level renderer tests. DEFERRED (acceptance 4''s persistence): the fold level is a switchable ConversationView parameter (proven by tests; none/L1/L2/L3 re-group) but is NOT yet wired to a persisted user setting + a UI control — the live pane uses the L1 default. Follow-up: read it from settings (ctx.settings) + a control to change it. Did NOT match the wireframe pixel-for-pixel; functional shape per the refinement decisions.', 'done', 'medium', NULL, NULL, NULL, '2026-06-03 11:15:39', '2026-06-03 15:17:33', NULL, '2b4264775c71be283f67c22aabc117f1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM46ANXPW02XGD3D3NG', 'task', NULL, 'Theme picker polish: collapse -hc into a checkbox, sort, widen, palette icon', 'User feedback on the T-234 status-bar theme popover (screenshot 2026-06-03): (1) the menu order looks random — the -hc variants are interleaved. (2) Hide all the high-contrast theme entries and replace them with a single ''[ ] High contrast'' checkbox at the top; toggling it applies the -hc sibling of the chosen base theme. (3) Widen the popover so long names (''Catppuccin Mocha'') don''t wrap; rows ellipsize. (4) The status-bar items (''application ok'' + theme switcher) float in the middle (boxed into the workspace column between the per-column bottom rails) instead of hugging the window''s right edge — align them right. (5) Replace the swatch dot on the trigger with a Phosphor icon (palette, 0xe6c8, already in the registry — paint-roller has no registered codepoint). Apply the -hc-as-checkbox + sort to the modal picker_view too for consistency. Helpers: base = name without -hc/-cb; show base themes only; resolve(base, hc) -> base+''-hc'' if it exists.
Done (2026-06-03): popover collapses -hc into a ''High contrast'' toggle, lists base themes sorted by display name, widened to 280 with ellipsis, palette icon on the trigger, lowercased bar label; status bar split so the global right group (tool status + theme switcher) hugs the window''s right edge past the context rail (643417e), keeping the 3-rail structure. Pure theme_families helpers unit-tested + popover widget tests. DEFERRED: applying the same -hc-toggle + sort to the modal picker_view (ctrl+k) for consistency — minor, follow-up.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-03 15:43:45', '2026-06-03 21:29:47', NULL, '881382733953e72c2de56ba5a3fdc9fe', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5V7GHH42RHV04DPTR', 'story', NULL, 'Vim keybinding preset', 'Ship a Vim-compatible keybinding preset with modal editing support (normal/insert/visual modes). Maps Vim motions and commands to clide editor and navigation actions. Users select it in settings.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place; modal Vim presets are more involved than the VS Code preset (T-64) because modes need to be expressed as scope flags (`vim.normal`, `vim.insert`, `vim.visual`) that the when-clause grammar can branch on. Implementation work:
1. Author `assets/keymaps/vim.yaml` using the typed Intents + `command:<id>` bindings.
2. Add a small mode-tracking service that publishes `vim.<mode>` scope flags via `KeymapService.setScopeFlag`.
3. Bind `Esc` to mode-reset → normal; `i` (when `vim.normal`) → enter insert; etc.
**Acceptance:**
1. `assets/keymaps/vim.yaml` ships covering the documented Vim default keybindings for editor / navigation / panes.
2. `KeymapService.setPreset("vim")` + the mode-tracking service together produce correct mode transitions.
3. A regression test exercises a representative motion (`j` → cursor down) and a mode change (`i` → insert).
Refinement (2026-06-01): scoped Vim-first for a Vim-power-user demo this weekend. ''Author a YAML file'' was wrong — T-117 shipped single-chord resolution + scope flags + when-clauses only. Decomposed into children: T-204 (fix dead default preset — live bug), T-205 (key-sequence + count resolution), T-206 (modal editor motion/edit intents), T-207 (Vim mode service + status indicator). T-65 itself becomes assets/keymaps/vim.yaml + regression tests once children land. T-64/T-66 deferred (single-chord, easy; T-205 hands JetBrains shift+shift later). Foundation is shared, not Vim-only.', 'done', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-04 12:00:09', NULL, '217a76ef73823789521206612b9835c8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5BWXW61EVYGMZD47G', 'story', '06FB0TNQM6S7FDAXQX32JNCSFG', 'Edit pencil in the reader action bar — open the current doc in the editor', 'Since clicking a .md now opens the READER (T-187), give the user a deliberate one-click way to open the SAME doc in the editor in edit mode. Add an edit pencil to the action bar of BOTH sidebar readers (markdown reader, decision reader). The pencil fires the editor path the readers moved away from for plain clicks: kernel.ipc.request(''editor.open'', {path}) on the reader''s current doc. Markdown reader: its current file path. Decision reader: the D-record''s backing file (decisions expose file_path = governance/decisions/<domain>.md via pql decisions read; resolve and open that — note it opens the whole domain file, since a decision is a section within it, unless we later add anchor support). Lives in the same reader chrome as back/forward (T-189) and pin (T-190). Custom chrome only (no Material). Acceptance: a pencil button in each reader opens the currently-shown doc in the editor; widget tests. Sequence with the other reader-chrome work (Wave B).', 'done', 'medium', NULL, NULL, NULL, '2026-05-31 11:58:19', '2026-06-04 12:00:09', NULL, '37b451f2b65b9bb9c0bca27383b84c69', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5ABRPK2SPW8VY7RP4', 'story', '06FB0TNQM6S7FDAXQX32JNCSFG', 'Pin a doc in the markdown & decision readers (single-slot quick-return)', 'Add a one-click PIN to both sidebar readers. Semantics per the reporter: a SINGLE pin slot per reader — clicking pin remembers the CURRENT doc; setting a new pin REPLACES the previous one (no list to manage); the pinned doc is always one click away to return to, so it is easy to use inline as you work. It is a quick-return bookmark, NOT a freeze-incoming toggle. Add a pinned-doc ref to _MarkdownViewerState and _DecisionDetailViewState; a pin button (set/replace) plus a jump-to-pin affordance in the reader chrome; persist for at least the session. Custom chrome only (no Material). Acceptance: pinning a doc lets you one-click return to it later; pinning another replaces it; works on both readers; widget tests. Sequence after the reader-open bug fixes (T-187/T-188); pairs naturally with back/forward (T-189).', 'done', 'medium', NULL, NULL, NULL, '2026-05-31 11:38:42', '2026-06-04 12:00:09', NULL, '58323eaf0b443895404e88ff7f9977d1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM56FKPV4WSPWA0KMB4', 'story', '06FB0TNQM6S7FDAXQX32JNCSFG', 'Back/forward navigation on the markdown & decision readers', 'Add Back/Forward buttons to both sidebar readers so you can return to what you were reading. Neither reader keeps history today: _MarkdownViewerState (markdown_viewer.dart) holds only _path/_content; _DecisionDetailViewState (decision_detail_view.dart) holds only _decision. Add a history stack + index to each, pushing on each new open; Back/Forward walk the stack and load the entry WITHOUT re-publishing a ''selection'' (in-widget navigation). For the decision reader, back/forward must not trigger the extension tab re-contribution — coordinate with the decision-open bug fix (T-188), which should stop re-contributing per selection. Custom chrome only (no Material). Acceptance: Back/Forward controls on both readers walk reading history and disable at the ends; widget tests. Sequence after the reader-open bug fixes (T-187/T-188) since it shares those widgets.', 'done', 'medium', NULL, NULL, NULL, '2026-05-31 11:38:36', '2026-06-04 12:00:09', NULL, '8e500bb51805cf46d8abad4748f6e9a2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4W624VD4JW1GHDHAR', 'task', '06FB0TNQM5HM3N07VB3RPFJCFM', 'Claude sidebar sub-tabs: Activity / Team / Config', 'The Claude meta-sidebar is overloaded (roster + tasks + messages + stats + config do not fit one scroll). Split it into a sub-tab strip (reuse the search-panel mode-strip pattern) with three tabs: (1) Activity — stats + live runtime visibility (T-141/T-157); (2) Team — roster + tasks + message-feed widget (T-171 + T-180), mostly hidden when solo and auto-fronted when a team is live; (3) Config — the Claude-environment view (T-183). Default to Activity. SHARED GEOMETRY: Activity values and the Config settings table use the SAME two-column label->value table (same column x + row pitch + section-header style) so toggling between tabs does not visually jump. Acceptance: a sub-tab strip switches the sidebar body between the three surfaces; Activity + Config settings render on the shared table so toggling is seamless; Team auto-fronts on team spawn; widget tests for tab switching. Wireframes: docs/design/wireframes/claude-prompts/07-sidebar-activity-tab.png, 08-sidebar-team-tab.png, 09-sidebar-config-tab.png. Relates to T-141, T-157, T-171, T-180.', 'done', 'medium', NULL, NULL, 'D-77', '2026-05-25 09:26:31', '2026-06-04 12:00:09', NULL, 'bee23004633073deaa93cb2d0154aa1e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM41S6RRTMTY9TYZA50', 'task', '06FB0TNQM614M7ES54V3SETAQM', 'T-115 follow-up: tap-driven widget test for sticky-startup toggle', 'T-115 follow-up: tap-driven widget test for sticky-startup toggle.
Adding a tap-driven (or even render-only) test that pumps WelcomeView with a non-empty recents list hangs the Flutter test runner until timeout.
Investigation 2026-05-22 (narrowed the cause):
- The hang is SYNCHRONOUS: `flutter test --timeout 45s` never fires; only an external `timeout` kills it. So the Dart isolate event loop is blocked, not awaiting.
- Reduced to a minimal repro: pumping two NESTED ClideTappable widgets under the shared test harness (test/helpers/widget_harness.dart) hangs. A SINGLE ClideTappable (as in _ActionRow) renders fine — that is why all existing WelcomeView tests pass (empty recents = no nested tappable). The recents path nests one: _RecentRow is a ClideTappable whose subtree contains _StickyToggle (a Semantics-wrapped ClideTappable).
- The shared harness uses `Overlay(canSizeOverlay: true)` over a zero-size `MediaQuery`, which triggers an intrinsic-sizing pass. Strongly suspect the nested ClideTappable (Focus + MouseRegion + GestureDetector + DecoratedBox stack) diverges under intrinsic dimension computation.
- Workarounds that did NOT help: setting tester.view.physicalSize; wrapping in SizedBox; wrapping in Center+SizedBox; a custom bounded MediaQuery harness (that custom harness hung even on a single tappable — likely its own confound, do not reuse it).
- The prior ClideTooltip/MouseRegion-timer hypothesis is WRONG: the recents widgets pass no tooltip to ClideTappable, so no ClideTooltip is built, and _StickyToggle uses Semantics(tooltip:) (metadata only, no widget/timer).
Next steps:
- Decide whether this is a real ClideTappable bug (nested tappables would also hang the live app welcome screen with recents) or strictly a canSizeOverlay-intrinsic-sizing test artifact. Check the running app: open welcome with >=1 recent project and confirm it does NOT hang. If the app is fine, the fix is harness-side (give WelcomeView tight constraints so no intrinsic pass), and the shared harness or a welcome-specific harness needs adjusting. If the app DOES hang, ClideTappable has a real nested-layout bug — fix it (likely in lib/widgets/src/clide_tappable.dart intrinsic/layout handling) and this becomes higher priority.
- Once unblocked, this also unblocks lib/builtin/welcome/src/welcome_view.dart coverage (74 uncovered lines, the dominant lib/builtin/ gap toward the T-89 95% target).
ProjectManager sticky-startup logic is already covered by test/kernel/src/project_test.dart (14 cases).
ROOT CAUSE FOUND + FIXED (2026-06-05). The strand is NOT nested ClideTappable / intrinsic sizing (that prior hypothesis is disproven: seeding an EMPTY recents list — no rows, no nested tappables — also hangs, while rendering real recent rows seeded correctly passes). Real cause: SettingsStore.set does real file I/O (writeAsString); awaiting settings.set + project.loadRecents INSIDE the testWidgets body runs that I/O in fake-async, which traps the completion callback so the await never returns (a +0 strand only SIGKILL clears). FIX: seed recents via tester.runAsync(() async {...}) (real event loop), then pump in a tight bounded tree (not the shared harness, whose unbounded width is a separate WelcomeView layout hazard). test/builtin/welcome/widget_test.dart now has working recents render + sticky-toggle + open-recent tests; the skip is removed. Harness lesson recorded.', 'done', 'medium', NULL, NULL, NULL, '2026-05-18 09:06:59', '2026-06-05 13:08:05', NULL, 'c94dba8a56ab16c17e26439dece58f47', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM66TK8TQ1X5T9GK6V0', 'task', '06FB0TNQM4XZA5399D2EHYCVFC', 'Decide diff view placement', 'Diff view currently contributes to workspace. Spec doesn''t place it. Likely belongs in context panel (viewer) or as an editor overlay. Depends on Q-27.
DECIDED 2026-06-05 (D-84): diff view = editor-mode surface, inline above Claude (NOT a workspace tab, NOT a context-panel viewer), spawned from the git sidebar. Rationale: a diff is a review+intervention surface needing room for hunk stage/discard and conflict-resolution widgets — too cramped for the ~420px context panel; the middle column above Claude gives space while keeping Claude''s prompt bar fixed (D-47/D-49). This decision ticket is satisfied; the actual re-placement (generalize the editor-mode slot to host a diff, wire git-sidebar spawn, build resolution widgets) is a follow-up implementation story.', 'done', 'low', NULL, NULL, 'D-48', '2026-04-22 21:03:40', '2026-06-05 15:17:09', NULL, 'eb79594591de24156f21a5a3deca97f9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5WT3BHB8JZY77G5AW', 'story', '06FB0TNQM4XZA5399D2EHYCVFC', 'Move diff view to the editor-mode surface (D-84): spawn from git sidebar, above Claude', 'Implements D-84. Today the diff view is a workspace TAB (lib/builtin/diff/src/extension.dart, slot: Slots.workspace) — remove that. Instead: (1) generalize the editor-mode surface (currently hosts the single inline editor in _WorkspaceSlot, app.dart, gated by arrangement.editorOpen/editorRatio) so it can host EITHER the editor or a diff (mutually exclusive in that slot, like the viewer<->editor swap in D-49); (2) selecting a changed file in the git sidebar opens its diff in that above-Claude surface; (3) the diff keeps Claude''s prompt bar fixed (D-47) and gets a draggable divider like the editor. Acceptance: diff no longer appears as a workspace tab; clicking a file in the git panel opens its diff above Claude; closing it (Ctrl/Cmd+W) returns to Claude full-height; coexists with the editor by swap, not both at once. Hunk stage/discard + conflict-resolution widgets can be a further follow-up. Tests: arrangement state for the diff surface; git-sidebar -> diff open wiring; widget render.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-05 15:14:25', '2026-06-05 15:17:09', NULL, 'f97772f7c06252c76de0d158a7d6c3d8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4KPN4VK4EZP013NNG', 'bug', NULL, 'Claude pane shows no UI or status-bar progress while compacting', 'When a Claude Code session hits context compaction, the Claude pane gives no signal that anything is happening: the conversation surface shows no in-pane indicator and the status bar shows no progress. From the user''s side the pane just appears to stall until compaction finishes and the next response streams in.
Expected: compaction is a visible, first-class state.
- In-pane: a clear "Compacting context…" affordance on the conversation surface (e.g. a transient status card / banner in the live-status line from T-168) so it''s obvious the session is busy, not wedged.
- Status bar: a progress indicator in the focus-driven Claude slot (T-150) reflecting the active compaction, consistent with the long-running-operation pattern in T-59.
Implementation notes:
- Source the state from the stream-json event stream (T-164/T-165), not transcript tailing — identify the compaction signal (system/compact_boundary or equivalent event) and thread it through the conversation controller into both the in-pane live-status line and the status-bar slot.
- Clear the indicator when compaction completes and normal streaming resumes.
Acceptance: triggering /compact (or an auto-compaction) shows an in-pane "compacting" indicator and a status-bar progress affordance for the duration, both of which clear when it finishes; covered by a unit test against a canned event fixture containing the compaction event.', 'backlog', 'medium', NULL, NULL, 'D-77', '2026-06-05 15:21:10', '2026-06-05 15:24:35', NULL, '8f0b6c74540f9e8d68c2ce076d8f13fb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5YYY4FVPX9KRVHCMC', 'story', NULL, 'toast notification system', 'Non-modal toast notifications for operation feedback (git push succeeded, extension activated, update available, errors). Slide in from bottom-right or top-right, auto-dismiss after timeout, manually dismissable. Queue multiple toasts. Severity levels map to status tokens (success/warning/error/info).
REFINED 2026-06-05. Build (bottom-right, system + proof emitter):
- kernel ToastService (ChangeNotifier): show(message, {severity, duration}) -> id, dismiss(id), queue of active toasts, per-toast auto-dismiss Timer (default ~4s; errors longer/sticky), cancel on manual dismiss. ToastSeverity {success, warning, error, info}.
- ClideToast widget (custom, NO Material per D-7): severity accent from status tokens (statusSuccess/Warning/Error/info), message text, dismiss affordance (x / Esc-less, click); slide+fade in from bottom-right, auto slide-out.
- ToastOverlay: mounted in the app-root Stack (app.dart) alongside ClidePalette/QuickOpenOverlay, anchored bottom-right; stacks multiple toasts vertically; animates enter/exit.
- a11y: Semantics liveRegion for the message + labelled dismiss; keyboard-dismissable.
- Proof emitter: git push success/failure raises a toast (wire in the git command path). Broad wiring (extension activated, update available, generic errors) = follow-up tickets.
- Tests: ToastService queue + auto-dismiss (fakeAsync timers) + severity mapping; ClideToast render per severity; a11y contract; overlay mount.', 'done', 'medium', NULL, NULL, NULL, '2026-04-23 20:31:48', '2026-06-05 21:12:23', NULL, 'e0477e4edfd5043e7f47082375985ed6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM66DNEK5FS9C0T4J5R', 'task', '06FB0TNQM5YYY4FVPX9KRVHCMC', 'clide ui toast — raise a toast from the CLI (D-6 drive-half for T-50)', 'Implements the agent/CLI drive-half of T-50 (parity, like ui.open/T-231): clide ui toast "message" [--severity success|warning|error|info] [--duration MS]. Registered as ui.toast in lib/src/daemon/ui_command.dart; publishes {message,severity,durationMs?} on the kernel MessageBus ''toast'' channel (literal kept Flutter-free), which the ToastService consumes. Lets a hosted Claude session (or any script) surface ''done/failed'' on the user''s screen. Validates message-required, severity, integer duration; toolError when no live GUI. Tested in test/daemon/ui_command_test.dart. NOTE: a running GUI must be rebuilt to pick this up.', 'done', 'medium', NULL, NULL, NULL, '2026-06-05 21:20:00', '2026-06-05 21:24:52', NULL, 'b0058695aa24a1418720d2d33ef1c51e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6DH7C6GQN9WNKQNXR', 'bug', NULL, 'pane list omits the loaded subject — CLI can''t tell which ticket/decision/file a detail pane shows', '`clide pane list` reports every GUI tab (T-219, D-83) but only its generic chrome: `id`, `title`, `active`, `visible`. For the context-slot detail panes the title is a static word — `tickets.detail` → "Ticket", `decisions.detail` → "Decision", `editor.active` → "Editor" — so the CLI cannot tell *which* ticket / decision / file the pane currently shows. The loaded subject lives in the view''s reader-nav state and never reaches the wire.
Concrete failure: with a ticket open in the right-hand context pane, there is no `clide` verb that answers "what ticket am I looking at". `pane list` shows `{"id":"tickets.detail","title":"Ticket","active":true}` and stops there. This breaks D-6 parity — the UI surfaces the open ticket; the CLI can''t observe it.
Fix: give `ViewPane` (lib/src/panes/view_pane.dart) an optional `subject` (and/or `subtitle`) field and have `snapshotViewPanes` (lib/kernel/src/panels/view_pane_snapshot.dart) populate it from the active reader-nav selection for detail panes — `tickets.detail` → `T-NNN`, `decisions.detail` → `D-NNN`, `editor.active` → the file path. Serialise it in `ViewPane.toJson` so `pane list` carries it. Keep ViewPane Flutter-free (it must stay usable under `dart test`); read the selection in the kernel-side snapshot, not in the value object.
Acceptance: with a ticket open in the context pane, `clide pane list` returns that pane with its loaded id (e.g. `"subject":"T-244"`); same for an open decision and an open editor file; panes with no subject omit the field; covered by a unit test over a snapshot with a populated reader-nav selection.', 'backlog', 'high', NULL, NULL, 'D-6', '2026-06-05 21:28:44', '2026-06-05 21:29:50', NULL, '31e0b8f73eae2c4c23d8bbe8265c4c53', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6QCYT7JBF352YVJ48', 'bug', NULL, 'clide CLI ignores CLIDE_SOCK and auto-discovers; stale sockets orphaned in runtime dir', 'The `clide` CLI does not honor the `CLIDE_SOCK` env var — it auto-discovers a running instance instead. Proof: with `CLIDE_SOCK=/run/user/1000/clide/DOES_NOT_EXIST.sock`, `clide version` still returns `{"version":"2.1.0"}` from the live app. Separately, the runtime socket dir accumulates orphaned socket files: `/run/user/1000/clide/` held two `.sock` entries while only one GUI process (the `build/linux/x64/debug/bundle/clide` bundle) was running, so a previous run''s socket was never cleaned up.
Why it matters: today (single instance) it''s harmless, but it''s a latent split-brain + observability hole. If two clide instances are ever live on the same machine, the CLI attaches to whichever discovery resolves first, with (a) no way to target a specific instance and (b) no way to find out which one you''re talking to. Combined with the stale-socket litter, `clide` could silently drive the wrong window. This is a D-6 surface gap — the CLI must be able to address the same instance the user is in.
Fix (scope to confirm):
- Honor `CLIDE_SOCK` when set (explicit target beats discovery); error clearly if that socket is dead rather than silently falling back.
- Clean up orphaned/stale socket files on app startup (and on clean shutdown) — detect a dead listener and unlink before binding a new hash.
- Add a way to enumerate/identify live instances (e.g. a `clide instances` verb, or include the instance id/socket path + pid in `clide version`) so a human or agent can pick the right one.
Acceptance: a bogus `CLIDE_SOCK` fails loudly instead of returning data from a different instance; a valid `CLIDE_SOCK` pins the CLI to that instance; startup leaves exactly one live socket for one running app (no orphan accumulation); there is a CLI affordance to list/identify running instances.', 'backlog', 'medium', NULL, NULL, 'D-6', '2026-06-05 21:28:48', '2026-06-05 21:30:04', NULL, '7fd8f211a761d1974b5b51fd7026e594', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5F4D2Q363QF7V40K8', 'bug', NULL, 'Status-bar right group still not hugging the center block''s right edge', 'Unresolved after T-237 (commits 643417e then reverted by dc84f4c). GOAL: in the single bottom status bar, left items (git branch ''main'', skills count) sit flush-LEFT and the global right group (ipc ''application ok'' + theme switcher) sits flush-RIGHT of the CENTER (workspace) block — NOT the window edge, and NOT two separate left-aligned columns. Keep the 3-rail bottom bar (sidebar rail | workspace status | context tab-rail). CURRENT STATE (dc84f4c): StatusbarHost is a single Container(Row[ ...left, Spacer(), ...right ]) with NO alignment, sitting in Expanded(StatusbarHost()) inside the bottom-bar Row (app.dart ~261). In theory the Spacer should right-align the right group within the workspace Expanded; in practice (verified live by the user, even after a hot RESTART) the right group does NOT hug the center block''s right edge. ATTEMPTS THAT FAILED: (a) removing ''alignment: Alignment.center'' from the StatusbarHost Container; (b) splitting into two hosts and trailing the right group past the context rail (overshot to the window edge — wrong). LEAD / NEXT STEP: instrument the actual constraints rather than guess. Prime suspect: the OUTER Column at app.dart ~214 has NO crossAxisAlignment (defaults to center), so the bottom-bar Container may receive LOOSE width and size to its content, collapsing the Spacer (right group ends up adjacent to the left group, both effectively left-aligned). Try CrossAxisAlignment.stretch on that Column (or give the status Container an explicit full width), and confirm Expanded(StatusbarHost) actually receives a bounded full-workspace width. A widget/integration test asserting the right group''s x-offset == workspace-region right edge would lock it. Files: lib/app.dart (StatusbarHost + the bottom-bar Row + the outer Column).
FIXED (2026-06-05): root cause confirmed by probe at 3440px width — the bottom bar used Row[ ...left (incl. a Flexible flex:1 loose item, the Claude status marquee), Spacer(), ...right ]. The Spacer (Expanded, flex:1) and the left flex:1 item SPLIT the free space 50/50, so the Spacer only pushed the right group by HALF the free space. That drift is proportional to width: tiny at 1200px (why probes/normal screens looked fine), ~1500px at 3440px (the right group floated to mid-bar) — hence ''only on ultrawide''. Probe: OLD R.right=1934 at a 3440 edge; NEW R.right=3440. FIX (app.dart StatusbarHost): explicit two-column layout — left group wrapped in Expanded(Row[...]) so it absorbs ALL free space (flex item flexes within it), right group trails at intrinsic width → hugs the workspace block''s right edge by construction, width-independent. Regression test test/app_statusbar_test.dart asserts the right group at width-8 for BOTH 600px and 3440px. Full fast suite green.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-03 21:41:58', '2026-06-06 06:44:40', NULL, '8534c325d61e138f74d6d03ca85496f4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5F2GC0ABFFVAH34EC', 'task', NULL, 'Apply the -hc-as-checkbox + sort to the ctrl+k theme modal (T-237 follow-up)', 'T-237 redesigned the status-bar theme POPOVER (theme_status_item.dart): base themes only, sorted by display name, a ''High contrast'' toggle that applies the -hc sibling, palette icon. The MODAL picker (picker_view.dart, opened by theme.pick / ctrl+k) still lists every theme including the -hc rows in available order. For consistency, apply the same treatment to picker_view: use the theme_families helpers (baseThemes / isHcName / resolveThemeName, already built + unit-tested in lib/builtin/theme_picker/src/theme_families.dart) to show base themes sorted, with a High contrast checkbox at the top. Low priority — the modal works; this is consistency polish.
DONE (2026-06-06). Re-scoped per user: instead of just applying the -hc-checkbox+sort polish to the theme modal, the modal was PROMOTED to a general Settings modal (picker_view.dart -> settings_view.dart, ThemePickerView -> SettingsView). ctrl+k / theme.pick now opens Settings, whose only section today is Appearance: base themes (sorted) + a High contrast toggle, reusing the theme_families helpers (shared with the status-bar popover, T-237). Command id kept as theme.pick (welcome link + tests reference it); retitled ''Settings...''. Extensible: more sections later (or split to a builtin/settings extension when it grows). Tests in test/builtin/theme_picker/widget_test.dart (base-only list, hc toggle applies sibling). NOTE: status-bar popover (T-237) was already done — that is what the user saw ''done in the ui''.', 'done', 'low', NULL, NULL, 'D-6', '2026-06-03 21:11:40', '2026-06-06 07:24:00', NULL, 'e6a5dd2bf5172a8b88ccac69014815be', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7ZQNDVPAPFSD9KYS4', 'task', '06FB0TNQM5GSR5WPC2T7CJM8XW', 'Command palette: pinned commands + cross-session recency persistence (T-23 follow-up)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-06 07:40:43', '2026-06-06 07:40:43', NULL, '966e6962f8f2c381c3d65a18edfa01f6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4BVE5EYM92CWGSXA4', 'bug', NULL, 'Permission-mode badge + Ctrl-M cycle don''t update the status bar', 'Symptom: the permission-mode badge in the Claude status line does nothing visible when clicked, and Ctrl/Cmd+M (cycle mode, T-226) likewise appears to do nothing. Either the mode isn''t actually changing, or — more likely — it changes in the Claude process but the status-bar tracker is never updated.
Likely root cause (confirmed by reading the code): the cycle is fire-and-forget with no status feedback.
- Composer Ctrl/Cmd+M -> widget.onCycleMode -> _ClaudePaneState._cycleMode (lib/builtin/claude/src/claude_pane.dart:302) -> s.setPermissionMode(nextSafePermissionMode(...)).
- The badge click path (_ModeBadge.onCycle) routes to the same _cycleMode, so both symptoms share one cause.
- StreamJsonSession.setPermissionMode (lib/builtin/claude/src/stream_json_session.dart:671) only writes a `set_permission_mode` control_request to the process. It does NOT optimistically update `_status` nor emit on `_statusCtl`.
- `_status.permissionMode` is only ever set from a `system/init` event via `_statusFromEvent` (stream_json_session.dart:567-571). A set_permission_mode control_request returns a control_response, which is not folded into status — so `_status` never re-emits, the badge label (claude_pane.dart:105-122, driven by `_status` over statusStream) is stale, and the UI looks dead.
So the status bar / badge value is not updated on change — matching the reported hypothesis.
Fix direction:
- Optimistically merge the new mode into `_status` and emit on `_statusCtl` immediately after writing the control_request (mirror how other state transitions surface), and/or handle the `control_response` for set_permission_mode and reconcile from it.
- Separately verify the control_request is actually honored by the Claude process (the ''they don''t work'' branch) — if the response reports failure, surface it rather than silently assuming success.
Acceptance:
- Clicking the badge and pressing Ctrl/Cmd+M both visibly cycle default -> accept-edits -> plan -> default in the status line.
- The displayed mode reflects the session''s actual mode (reconciled from the response / next init), not just an optimistic guess.
- Regression test at the session level: setPermissionMode emits an updated SessionStatus on statusStream.
Refs: T-226 (interactive mode badge + Ctrl/Cmd+M), T-181 (bypassPermissions behind confirmed path — keep excluded from the safe cycle), D-78 (interaction-zone / display-only conventions).', 'done', 'high', NULL, NULL, NULL, '2026-06-06 07:26:50', '2026-06-06 07:49:08', NULL, 'cb4f4331f0aedf5a61dd350ab1dfdde7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7JXD3FKW91934D0W0', 'story', NULL, 'Image viewer conversation card + plumbing', 'Add an image-viewer card to the Claude conversation log, with the CLI verb that drives it (D-6 parity).
Scope:
- Conversation card: render an image inline in the Claude conversation widget — clide-owned rendering (no opinionated package), display-only per D-78 (no inline interactive controls; any controls belong in the interaction zone). Respect theme tokens / ui-design.
- Plumbing: a `clide` verb to show an image in the log (e.g. `clide image show <path>` or via the pane subsystem), accepting a workspace path; define accepted formats (PNG/JPEG/...), path resolution (workspace-relative), and sizing/scaling behavior.
- Parity (D-6): the verb is the CLI counterpart of the card; ensure it registers in the dispatcher so it shows up in the discovery verb from T-248.
Dependency: pairs with T-248 — without the /clide skill + discovery verb, Claude won''t know this card/verb exists even once shipped. Build the plumbing here; T-248 makes it discoverable.
Refs: D-6 (parity), D-78 (interaction zone, display-only conversation widgets). Related: T-248.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-06 07:21:17', '2026-06-06 08:21:40', NULL, '2c320b610b77976f25199581c3d04f04', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM70TW38E78FFXDHYAC', 'story', NULL, '/clide skill — advertise the clide tool surface to Claude', 'Create a `/clide` skill (none exists today — only legacy templates under legacy/clide/templates/skills/) that advertises the clide CLI surface to Claude so new affordances become discoverable.
Problem: my knowledge of the clide surface comes from a hand-curated session blurb. There is no runtime discovery — `clide help` is a stub, `clide <subsystem>` with no verb just prints `usage:`, and the dispatcher''s registered command table (e.g. pane_commands.dart) is never exposed. D-6 guarantees a verb EXISTS for every UI action, but parity != discoverability: a correctly-registered verb is still unreachable if nothing tells me it''s there.
Scope:
- Self-description first (prereq for a non-rotting skill): add a discovery verb that reflects the live dispatcher registry — e.g. `clide capabilities` (machine-readable JSON: subsystems -> verbs -> arg schema) and/or flesh out `clide help` to enumerate subsystems/verbs. Sourced from the registry so it never drifts.
- `/clide` SKILL.md: trigger description always visible to Claude; body points at the discovery verb rather than hard-coding a verb list, plus conventions (slots: sidebar/workspace/context, pane kinds, focus/spawn/close/write/resize). Thin and always-correct.
This is what makes T-249 (image viewer) and future panels reachable by Claude the moment they register — no skill edit per panel.
Refs: D-6 (CLI/event-surface parity). Related: T-249.
RE-SCOPE (2026-06-06, from T-208 completeness review): partly overtaken by reality — do NOT build from scratch.
- The `clide capabilities` verb already EXISTS in code: registered in lib/src/daemon/dispatcher.dart:12 (built-in) and as an umbrella command in lib/src/cli/argv_to_request.dart:28. Its handler (_capabilities, dispatcher.dart:86) enumerates the live registry with arg schemas — so the non-rotting design is already implemented server-side.
- A `/clide` skill is ALSO already present this session (other ongoing work), and it instructs agents to "Start with `clide capabilities` to enumerate the live tool surface."
- BUT: against the live instance, `clide capabilities` returns a usage stub at exit 0 (`usage: clide capabilities <verb> [args...]`) instead of the command table — i.e. the discoverability entry point the skill advertises is currently DEAD. Likely a stale running build or a C-client umbrella mismatch (native/clide-cli/clide.c forwards argv but the live server treats `capabilities` as a subsystem needing a verb).
New scope for this ticket:
1. Verify/fix the live `clide capabilities` path end-to-end (rebuild/restart vs real wiring gap in the C client / server umbrella handling). Acceptance: `clide capabilities` returns the JSON command table against a running instance.
2. Reconcile with the already-shipped `/clide` skill rather than authoring anew — confirm it points at the working verb and covers the conventions.
Net: this is now a verify-and-reconcile task, not a build-from-scratch one. Related: T-249, T-208.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-06 07:21:17', '2026-06-06 08:31:25', NULL, '2d81d44df3ca33ac1c5ada98d842519a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5NEXC0P46NJHACNM0', 'task', '06FB0TNQM7XMQ44AVCAARQZX14', 'Open a file in the diff panel for review via ui.open', 'Follow-up from T-231 (2026-06-03). T-231 added ''clide ui open <reader> <id|path>'' for the ReaderNav-backed readers (tickets/decisions/markdown), which open by subscribing to a MessageBus ''selection''. The DIFF panel is not covered: DiffController (lib/builtin/diff/src/diff_controller.dart) loads the git working-tree diff via the git.diff IPC verb and refreshes on git.changed events; it has no ''selection''/''focus'' bus channel, so ui.open has no entry point to target a file. Note: because the diff panel shows the working tree, an agent''s uncommitted edits already APPEAR there on the next git.changed refresh -- what''s missing is (a) bringing the diff tab to the front and (b) scrolling/filtering to a specific file. Proposed: give the diff view a bus subscription (publisher ''builtin.diff'', channel ''selection''/''focus'', {path}) that reveals its tab and focuses/filters that file''s diff (DiffController.load already accepts a paths filter), then add ''diff'' as a 4th ui.open target. Related gap: pane.focus is advisory-only (pane_commands.dart) so ''bring tab to front'' isn''t truly wired -- the reveal likely needs the same mechanism the readers use to reveal their tab on selection. Depends in spirit on T-232 (CLI args) only if a non-bus path is chosen; the bus path works regardless.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-03 13:29:59', '2026-06-06 09:20:02', NULL, 'f0131be14b518c6169966a8719b5700b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM78Q3VYWSE830CTVB4', 'task', '06FB0TNQM4QYYKHPQ03H3QD61W', 'In-app ''Install clide command in PATH'' affordance', 'VS Code-style: the GUI offers to install the `clide` shell command on PATH, for users who run the .app without `make install`. A command + command-palette entry that copies/symlinks the bundled C client to a PATH dir and reports success/failure. Acceptance: invoking it makes `clide` resolve on PATH from a fresh shell.
Refinement (2026-06-03, from live dogfooding): scope should include PROACTIVE detection on launch, not just a palette command. When the clide IDE starts in a repo, check whether ''clide'' resolves on PATH AND points to the C client (not a stale symlink to the GUI bundle runner) -- we hit exactly this: ~/.local/bin/clide was a May-6 symlink to ~/.local/lib/clide/clide (the Flutter GUI), so a bare ''clide pane list'' launched a second app instead of querying. If missing or stale, prompt/offer to install (copy the bundled C client to a PATH dir, VS Code ''Install code command'' style) and report success. This is what lets a fresh agent actually reach the CLI (D-83 names the hosted session primary, but an external agent benefits too). Detecting ''stale GUI symlink'' specifically: the target should be an ELF/Mach-O executable, not a symlink into the bundle. Alternative path the user raised: instead of/alongside this, make the /ide MCP surface reachable (T-225) -- but CLI is primary per D-68.', 'done', 'medium', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-06 10:02:03', NULL, '58895405f0e740d4b8db4a37e8ca3f87', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4QYYKHPQ03H3QD61W', 'epic', '06FB0TNQM7XMQ44AVCAARQZX14', 'Ship the clide CLI on PATH', 'Gap 1 (BLOCKER) from self-analysis.md. The C client native/clide-cli/clide.c (T-126, done) is the real CLI, but there is no supported way for `clide` to land on an agent''s PATH:
- `make clide-cli` (Makefile:284) is a non-default target; `build` (Makefile:169) does not depend on it, so the binary is never produced by a normal build (native/macos-arm64/clide had never been built).
- `make install` (Makefile:195) copies the GUI bundle and `ln -sf $(INSTALL_PREFIX)/clide/clide $(INSTALL_DIR)/clide` (Makefile:200) symlinks the FLUTTER GUI runner — not the C client — on BOTH macOS and Linux (on macOS that path doesn''t even resolve inside clide.app).
Net: `which clide` -> not found, `clide ping` -> exit 127. The CLI-first contract (D-1) has no delivery mechanism. Related to the broader installer epic T-46, but that is the GUI/runtime installer; this epic is specifically C-client-on-PATH delivery.', 'done', 'high', NULL, NULL, 'D-1', '2026-06-02 18:13:52', '2026-06-06 10:02:06', NULL, '5a5a8c818ff51c55ff8fd57914cce71b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4K2AAPK52Y8BEJ3YR', 'bug', NULL, 'Esc closes the file/pane instead of returning to Vim normal mode', NULL, 'done', 'high', NULL, NULL, NULL, '2026-06-06 14:01:03', '2026-06-06 18:58:25', NULL, '7c4ca77bf4e0ee1116bfd5b4964ef21a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5KYAV8TMH3Y3FPRSR', 'task', '06FB0TNQM5GSR5WPC2T7CJM8XW', 'wire command palette keybinding', 'D-48 names `⌘P` (fuzzy file open) and `⌘⇧P` (command palette) as the canonical keyboard navigation. The command palette overlay/widget exists; the keybinding is not yet wired.
**Progress (2026-05-17, T-117):** The keymap layer now binds `ctrl+shift+p` / `meta+shift+p` to `PaletteOpenIntent` in `assets/keymaps/default.yaml`. The intent resolves end-to-end through `KeymapService.resolveEvent` → `Actions.maybeInvoke`. **Still pending**: an `Actions` provider somewhere in the tree that handles `PaletteOpenIntent` by calling `kernel.palette.open()`, plus the arrow-key / Escape / Enter handlers on `ClidePalette` itself. Those land as part of T-100 (palette keyboard nav).
**Acceptance:**
- `⌘⇧P` (`Ctrl+Shift+P` on Linux, follows the kernel keymap normalization) opens the command palette overlay over the active workspace.
- Esc dismisses; Enter runs the highlighted command; arrow keys move the highlight.
- Commands listed are everything registered via `CommandContribution` across all activated extensions.
- Fuzzy match against command title; recent / pinned commands float to the top.
**Implementation hints:**
- Slot exists: `Slots.commandPalette` is reserved (lib/kernel/src/panels/slot_id.dart).
- Bindings live in the keymap (T-117) — not in `lib/kernel/src/commands/keybindings.dart` (that file is legacy).
- The overlay should not shift layout (D-48 chrome budget — no layout shift on palette open).
DONE (2026-06-06). Keybinding + nav were already wired (T-117 binds ctrl/meta+shift+p -> PaletteOpenIntent; _RootShell handles it -> palette.open(); T-100 added arrow/Enter/Esc nav + selected-index). Remaining acceptance implemented now: (1) FUZZY match — PaletteController.filtered() uses a shared subsequence matcher (lib/kernel/src/fuzzy.dart, extracted from quick_open so both share one source of truth), ranked best-score-first; (2) RECENCY — invoked commands float to the top on empty filter and break fuzzy-score ties (in-session MRU). DEFERRED: ''pinned'' commands + cross-session recency persistence need a pin affordance + settings storage — filed as a follow-up. Tests: test/kernel/src/commands/palette_test.dart + test/kernel/src/fuzzy_test.dart.', 'done', 'medium', NULL, NULL, 'D-6', '2026-04-22 14:08:40', '2026-06-06 19:06:30', NULL, 'a31b3cddfbdc254bd7b908b4ebfce28c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM72ACQSDBJTKRBC58W', 'task', '06FB0TNQM620F5ENRYNT9S0JDW', 'Add cursor-based pull events: clide events --since <cursor>', 'Return all events since a cursor plus a new cursor, alongside the existing stream (T-129). Acceptance: `clide events --since <c>` returns events + next-cursor with exit 0; repeated calls neither drop nor duplicate. Coordinate the cursor/persistence design with open questions Q-2 (event back-pressure) and Q-3 (event persistence + audit) — this dovetails with both.
Refinement (2026-06-06): blockers resolved. Q-2 + Q-3 closed by D-85 (event bus delivery). Design is now fixed: serve --since from a bounded in-memory ring keyed by a monotonic cursor; return events-after-cursor + next-cursor, and a gap marker (per-subscriber dropped-count) when the requested cursor has aged out so callers detect loss rather than silently miss events. Back-pressure is drop-oldest (producer never blocks, subscribers never killed). No on-disk persistence in v1. Ready to implement.', 'done', 'medium', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-06 19:24:44', NULL, '8479cf97831fa506407dcffb548c1b13', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM620F5ENRYNT9S0JDW', 'epic', '06FB0TNQM7XMQ44AVCAARQZX14', 'Agent-friendly event observation (pull-based)', 'Gap 4 from self-analysis.md. `clide tail --events` (T-129) is a never-returning stream — awkward for a request/response agent loop; the agent must background it and poll a file or it misses events between polls. Provide a pull-based form that fits an agent loop natively.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-02 18:13:52', '2026-06-06 19:24:44', NULL, 'b5bc5abd92e28abe4f7c05369038f054', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5DYC7CRP8TMHADW8W', 'task', '06FB0TNQM7XMQ44AVCAARQZX14', 'MCP transport reachable by an external agent', 'Gap 6 from self-analysis.md. The MCP SSE server (T-130, D-68) is live but mcp__ide__* tools are not exposed to an external agent out of the box, and getDiagnostics/executeCode were noted as stubs. The CLI path is the priority (Epics A-C); MCP follows. Decide + wire the minimal reachable MCP surface, or explicitly defer with a note. Relates to open question Q-32 (minimum MCP tool surface).
Refinement (2026-06-06): tool-surface question resolved. Q-32 closed by D-86 — expose the full mcp__clide__* namespace, but GENERATE tools/list from the co-registered command registry (D-74) that already feeds the CLI + palette, so there is no hand-maintained second surface; add a per-command MCP opt-out for poor-fit verbs (long-lived streams, UI-side-effecting). Transport stays SSE-only per D-73 (Q-33 re-confirmed, not reopened). Remaining stubs to make real: getDiagnostics + executeCode. Scope is now: registry->MCP tool-definition adapter (arg-schema -> JSON-Schema), served over the existing SSE transport.', 'done', 'low', NULL, NULL, 'D-68', '2026-06-02 18:13:52', '2026-06-06 19:37:47', NULL, '7ac30a4d6cdb64bdf8d8b609c8388751', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7XMQ44AVCAARQZX14', 'initiative', NULL, 'Give Claude hands', 'Make a Claude agent able to actually WORK inside clide: reach the CLI, know clide is hosting it, and observe the same workspace the user sees. Source: self-analysis.md (2026-06-02), a live dogfood probe by a Claude (Opus 4.8) running against a live instance (socket + MCP SSE up, version 2.1.0).
Finding: the IPC/CLI foundation (D-1, D-6, D-56; built under T-99 incl. the C client T-126 and the `clide tail --events` stream T-129) works end-to-end at the socket level. But a fresh agent cannot REACH or OBSERVE it. Three blockers:
1. No `clide` binary on PATH, and no install path that delivers one (Epic A — blocker).
2. No bootstrap: nothing tells a fresh agent clide is running or where its socket is (Epic B).
3. The live UI panes / active file are not reflected in the CLI registries, so the D-6 parity premise (agent sees what the user sees) breaks (Epic C).
Plus: a governance decision on WHICH agent model is the dogfood target (hosted stream-json session vs external CLI driver vs both) and an MCP follow-up. The pleasant surprise per the probe: the hard part (a live, correct, single-process IPC contract with proper 0/1/3 exit codes) is done; what''s missing is the last mile that puts the tool in the agent''s hands. NOTE: the report says ''daemon'' but D-56 dissolved it — this is the in-process IPC server; the socket contract is unchanged.', 'done', 'high', NULL, NULL, 'D-6', '2026-06-02 18:13:52', '2026-06-06 19:53:07', NULL, '4f9ba8adbe68c68f76d76acda314dd3c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6S7KHYPE9W7S1JHZ8', 'task', '06FB0TNQM7XMQ44AVCAARQZX14', 'Install affordance: distinguish a dev-tree clide from a production-installed build', 'Flagged during T-212 (2026-06-06). The Install-clide-command affordance copies whatever CliInstaller resolves as the bundled C client into ~/.local/bin, and ''make run'' points CLIDE_CLI_BIN at the dev-tree client (native/<plat>/clide). On a normal user machine that is correct: the bundled client beside the GUI runner IS the production build. But on a clide DEV system the clide on PATH should point at the properly-installed production build, not a dev-tree binary or stale build artefact. CliInstaller.inspect only classifies missing / staleGui (points into the Flutter bundle) / installed, and treats any non-GUI binary as installed without distinguishing dev-tree vs a real install. Good enough for now per the user; the affordance works for end users and fixes the stale-GUI-symlink footgun. Decide desired dev-machine behaviour (e.g. detect dev tree via CLIDE_PROJECT / repo-relative path and warn instead of silently installing, or prefer an already-correct production build) and refine inspect() accordingly.', 'done', 'low', NULL, NULL, NULL, '2026-06-06 10:07:35', '2026-06-06 19:53:07', NULL, 'd2fec2ab90638b74a87ec39146730cdd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6WZ7DKVDFAGQ4QDMG', 'story', NULL, 'Terminal as a first-class editor-pane surface (swap vs split)', 'Spun off from the T-54 dock design (D-87), which scoped the bottom dock to read-only output (logs + problems) and explicitly kept the terminal OUT of it. Terminals are core functionality, not an afterthought: the terminal should live in the editor pane (above Claude, D-49 editor-mode) as a first-class peer of editor and diff — not in a read-only log strip.
First-class means: reachable by a dedicated keybinding (e.g. Ctrl+`), multiple terminals, and tmux-backed persistence (D-41/D-75) so swapping away never stops a running terminal — you swap back and the build is still streaming. Today the terminal is a generic workspace tab; this moves it into the editor-mode family.
OPEN QUESTION (decide here): clide''s editor-mode is swap (one surface above Claude at a time), so you cannot currently watch a terminal AND edit a file side-by-side. Options: (a) swap-only, leaning on tmux persistence + a fast toggle; or (b) allow the editor pane to split so a terminal can sit beside editor/diff. This is a facet of Q-27 (two-editor split) — resolve them together. Relates to D-47 (Claude-is-home), D-48 (chrome budget), D-49 (editor-mode), D-84 (diff placement, the existing editor-mode peer).', 'backlog', 'medium', NULL, NULL, 'D-49', '2026-06-06 20:55:49', '2026-06-06 20:55:49', NULL, '6108bdb6bc0ea059d93f12021fe617cf', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7RQMN1GKEZ5JQKJT0', 'epic', NULL, 'Interaction model — context panel, readers, live-sync, focus mode', 'Home for the D-47D-57 interaction-model surface that isn''t a numbered build tier: the reactive context panel (D-50), editor-mode viewer↔editor swap (D-49), panel collapse/spine (D-51), focus mode (D-52), layout state persistence (D-53), and the keyboard map (D-54). Created 2026-06-06 to re-home T-36, which was mis-parented under T-7 (Tier 5 canvas/graph). These behaviors span tiers and belong to the interaction model rather than a single feature tier.', 'backlog', 'medium', NULL, NULL, 'D-47', '2026-06-06 21:12:44', '2026-06-06 21:12:44', NULL, '22b26d155a9fdffa44909ed4811c42f4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JK0RHMRAH47K8ND4', 'story', '06FB0TNQM7RQMN1GKEZ5JQKJT0', 'Live-sync read-mirror: context panel tracks the open editor buffer', 'When an editor is open in edit mode on a renderable doc (.md), the right-hand context panel auto-opens a read-only view that mirrors the buffer and updates live as the user types (D-50 behavior 4). If the editor is on a non-renderable file, no auto-viewer (D-50 behavior 5, already holds).
Wiring sketch: EditorRegistry already emits editor.opened / editor.active-changed / editor.edited (lib/src/editor/registry.dart:85). The markdown reader (lib/builtin/markdown/src/markdown_viewer.dart) currently pulls content once via files.read with no subscription. The live-sync work is: (1) on editor.opened for a renderable file, auto-activate the markdown reader in the context panel; (2) subscribe the reader to editor.edited and re-render from the in-memory buffer rather than re-reading disk; (3) read-only — no edit affordances in the mirror.
HISTORY: T-36 originally bundled four D-50 clauses (parse Claude''s output for file references, swap the open viewer, badge the spine when collapsed, live-sync). The give-clide-hands push (T-208) superseded the first three: instead of clide scraping the terminal for references, the agent explicitly drives the reader via `clide ui open markdown <path>` (T-231) and ui.open -> diff (T-233). The spine badge was dropped (the open is now an intentional agent act, not a passive notification). Re-scoped 2026-06-06 to the one UI-owned piece that give-clide-hands did not deliver: the live-sync read-mirror. Re-homed from T-7 (Tier 5 canvas/graph, a mis-parent) to T-259 (interaction model). See the D-50 amendment.', 'backlog', 'low', NULL, NULL, 'D-50', '2026-04-22 20:34:09', '2026-06-06 21:14:22', NULL, '6609c1a39c7dd974a0055ab2ed80e3cd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6DSPH569T4458B8JC', 'story', NULL, 'Annotation tools on the image overlay — boxes, arrows, labels for visual dialog with Claude', 'Add an annotation layer to the full-screen image overlay (T-252) so the user can draw boxes, arrows, and text labels directly on a shown image and have a VISUAL DIALOG with Claude about it — Claude shows an image, the user marks it up ("this button is misaligned", arrow pointing at the bug, box around the region), and the markup is communicated back to Claude.
The crux is the round-trip: annotations are not just decoration, they are a message to Claude. The overlay must turn what the user draws into something Claude can consume.
Scope:
- Annotation layer (lib/widgets/): a CustomPaint overlay composited on top of the T-252 zoom/pan viewer. Tools: rectangle/box, arrow, freehand pen (optional), and text label. Own-the-rendering-stack — draw with CustomPaint, no opinionated annotation package. Annotation geometry is stored in NORMALIZED IMAGE-SPACE coordinates so it survives zoom/pan and maps cleanly back to the original pixels. Select / move / delete an annotation; undo/redo; per-annotation color from theme tokens (ui-design). Min one accent + a couple of palette colors, not a full color picker.
- Interaction surface (D-78): the overlay is a modal interaction surface, so the annotation toolbar lives in the overlay chrome (not inline in the display-only conversation card). Tool palette, color, undo/redo, clear, and a confirm/send affordance. Keyboard + AT reachable (ClideTappable / Semantics), Esc still dismisses.
- Round-trip to Claude — TWO outputs on confirm: (1) a FLATTENED PNG (image + annotations rendered together) written to a workspace path so Claude can view it via the existing image card / clide image show (T-249); (2) a STRUCTURED text summary of the annotations (tool kind, normalized coords, label text, color) so Claude gets machine-readable intent, not just pixels. Decide whether send drops both into the composer / interaction zone as the next user turn. This is the visual-dialog loop and the main design question of the ticket.
- Parity (D-6): a CLI counterpart so annotations are not UI-only — e.g. a verb to open an image with a starting annotation set, and/or to emit the flattened+structured result. Register it so it surfaces in clide capabilities (T-248).
Open questions to resolve in design:
- Exact send/transport: composer injection vs a dedicated clide verb that posts the annotated turn.
- Structured-annotation schema (shared with canvas/graph later?).
- Whether annotations persist with the image or are ephemeral per-overlay-session.
Refs: D-78 (interaction zone / display-only conversation widgets), D-6 (CLI/UI parity). Builds on T-252 (lightbox overlay) and T-249 (image card + clide image show). Related: T-248 (discovery), T-259 (interaction-model epic).', 'backlog', 'medium', NULL, NULL, 'D-78', '2026-06-06 21:46:42', '2026-06-06 21:46:42', NULL, '5322506b7dc2d05515fd978118b8d5de', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7GJW4V3CXMMXXV988', 'task', '06FB0TNQM7GCVGCH9RMGYHENCC', 'Output dock: drag-resize the dock height', 'Follow-up from T-54 (D-87). The bottom output dock ships with a persisted default height but no drag-to-resize handle. DragResizeHandle supports Axis.vertical but its drag-sign logic (lib/kernel/src/panels/drag_resize.dart _onMove + bumpedSlotSize) only negates for contextPanel; a bottom dock with a top handle needs the same negation (drag up = grow). Add Slots.dock to those sign cases, render a vertical DragResizeHandle above the dock in RootLayout (lib/app.dart, between the workspace Expanded and the dock SizedBox), and keep the existing >=50% clamp. Persistence of the size already works (project.layout.dock.size).', 'backlog', 'low', NULL, NULL, 'D-87', '2026-06-07 08:03:10', '2026-06-07 08:03:10', NULL, '63450e3c14cc719fb0da8a4a19d82c83', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7GCVGCH9RMGYHENCC', 'story', NULL, 'output and log panel', 'Bottom panel or context tab showing daemon logs, build output, extension logs, and pql sync output. Filterable by source. Auto-scrolls to latest. Useful for debugging extension and IPC issues.
UX design (2026-06-06, D-87): the panel is a bottom OUTPUT DOCK, read-only, two tabs — Output (the Logger stream, filter by source/level/text, auto-scroll) + Problems (moved out of the sidebar; no duplication). Toggled by a single status-bar widget that REPLACES the app-status indicator (merged health+log: green check when clean, warn/error counts when not, chevron for open state) — opens with click or Cmd/Ctrl+J. Needs a bounded in-memory ring sink on the Logger (no history today). Layout amends D-47 (dock pushes Claude up, capped so Claude stays >=50%). Terminal is NOT in the dock — kept first-class in the editor pane, tracked by T-258. Resolves Q-28. Wireframe: docs/design/wireframes/output-dock/.', 'done', 'medium', NULL, NULL, NULL, '2026-04-23 20:32:06', '2026-06-07 08:03:17', NULL, '411f3d80c7dba30b330678d58a27a74f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4QW8VH454J8V3E174', 'story', NULL, 'Full-screen lightbox overlay (zoom + pan) for image cards', 'Click an inline image card (T-249) to open it in a full-screen overlay with zoom + pan — the standard lightbox UX from other apps (dimmed backdrop, image fit-to-screen, scroll/pinch to zoom, drag to pan when zoomed past fit, Esc or backdrop-click to dismiss).
Build the overlay as a SHARED PRIMITIVE WIDGET, not image-specific. A reusable zoomable-viewer / lightbox primitive under lib/widgets/ (e.g. ClideLightbox + ClideZoomPanViewer) that takes any child/content; the image card is its first consumer, and the canvas, graph, and diff/image previews can adopt it later. Own-the-rendering-stack + prefer-zero-deps still apply: Flutter''s SDK InteractiveViewer is the SDK-first starting point (allowed), but keep the gesture/zoom logic in the primitive so we control the UX, not an opinionated package.
Scope:
- Shared primitive (lib/widgets/): a full-screen overlay with a dimmed backdrop hosted via the existing DialogRouter (single-at-a-time modal, D-78 interaction surface). Content scales to fit on open; zoom via scroll wheel / pinch / +- ; pan by drag when zoomed in; double-click toggles fit<->100%/2x; Esc and backdrop-click dismiss. Min/max zoom clamp. Keyboard + AT reachable (ClideTappable / Semantics), theme tokens / ui-design for any chrome (close affordance, zoom hint).
- Image card wiring (T-249): the inline card stays display-only per D-78 — the click is a navigation gesture that opens the lightbox, not an inline control. Render the same resolved file at full resolution in the overlay; missing-file degrades to the existing placeholder.
- Parity (D-6): a CLI counterpart to open an image straight into the lightbox (e.g. clide image show <path> --fullscreen, or clide image open <path>), registered so it surfaces in clide capabilities (T-248).
Refs: D-78 (interaction zone / display-only conversation widgets), D-6 (CLI/UI parity), DialogRouter (lib/kernel/src/dialog.dart). Builds on T-249 (image card + clide image show). Related: T-248.', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-06 08:28:43', '2026-06-07 08:32:36', NULL, '050945fb60f781edbc0a56552a3e5324', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM63WP564EZRERJHVCM', 'story', NULL, 'Animate the ''running…'' turn indicator with rotating status verbs', '## Problem
While a Claude turn is in flight, the composer swaps in a static, muted
`running…` label next to the Stop button (`lib/builtin/claude/src/claude_composer.dart:486`,
shown when `widget.busy && widget.onInterrupt != null`). It''s gray, motionless,
and a little lifeless — it gives no sense that anything is actually happening.
## Goal
Make the in-flight indicator feel alive:
1. **Animation.** Add subtle motion — e.g. an animated ellipsis (`running` → `running.`
→ `running..` → `running...`), a shimmer/pulse on the text, or a small spinner glyph.
Custom-painted / token-driven per the "own the rendering stack" guardrail; no
opinionated animation packages.
2. **Rotating status verbs.** Cycle through playful gerunds the way the Claude Code CLI
does (e.g. "Clauding…", "Flibbertigibbeting…", "Pondering…", "Conjuring…"). Pick a
word from a curated list and rotate it every few seconds while the turn runs.
## Open question — can we reuse the CLI''s words?
The Claude Code CLI ships its own list of these status verbs. Before hand-rolling our
own, check whether that list is something we''re allowed to reuse / surface (licensing,
where it lives, whether stream-json exposes the current one). If we can''t pull the CLI''s
list, ship our own curated, on-brand list instead. Document the decision.
## Notes / constraints
- Respect reduced-motion / accessibility settings — animation must be disable-able and
the a11y semantics should still read sensibly (the Stop button already carries the
interrupt hint).
- Use theme tokens for color; keep it muted/tasteful, not distracting.
- Touch point today is the `widget.busy` branch in `claude_composer.dart`; consider
whether the rotating-word state belongs there or in the session/orchestrator layer.
- Add a widget/golden test for the animated states (bounded pumps — no real timers).
Refinement (2026-06-07): open question resolved — ship OUR OWN curated verb list, do not reuse the CLI''s. Rationale: the spinner words are a TUI cosmetic not surfaced by the stream-json control protocol (so there''s nothing to read live), and extracting Anthropic''s bundled list is a licensing gray area. A clide-owned list aligns with ''own the rendering stack'' and D-75 (isolate/version-pin CC coupling). State lives in the WIDGET layer (a RunningIndicator in lib/builtin/claude/), not the orchestrator — it''s ephemeral UI. Animation via AnimationController (no Timers, so tests use bounded pumps); reduced-motion (MediaQuery.disableAnimations) shows a static verb. Ready to implement.', 'done', 'medium', NULL, NULL, NULL, '2026-06-06 09:56:23', '2026-06-07 08:51:08', NULL, '83072782536ca8de71773dab1e5c4a41', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM74JAV0MJMB4VQNTRG', 'bug', NULL, 'primary /clear must clear the deterministic session in place, not orphan to a fresh id', 'The primary Claude pane is anchored to a deterministic session id derived from the repo path (primarySessionId), so it resumes the same transcript across restarts (D-77/T-146). But clide-owned /clear (T-156) respawns on a fresh RANDOM id (freshSessionId). Next clide launch recomputes the deterministic id, finds its old transcript on disk, and resumes the PRE-clear conversation — the cleared session is orphaned. Continuity appears broken.
Fix: /clear in the PRIMARY pane should empty the deterministic session in place — delete its <id>.jsonl (and sidecar <id>/ dir) so ''claude --session-id'' re-creates it empty, then respawn on the SAME deterministic id. Secondary panes are throwaway and keep the fresh-random behavior. Factor the transcript path/dir construction (duplicated in claude_pane._spawn and _resumeFlow) into session_naming helpers (claudeProjectDir/claudeTranscriptPath/clearSessionTranscript) so the clear logic is DRY and unit-testable.', 'done', 'high', NULL, NULL, 'D-77', '2026-06-07 09:35:03', '2026-06-07 09:36:59', NULL, '62386d2e15f2218ba886babfb01b37ae', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6P9YQ5V7WS6RFJNHW', 'bug', NULL, 'primary Claude pane keeps the previous repo''s session after an in-place workspace switch', 'Separate clide WINDOWS are isolated (separate process, per-root IPC socket, per-repo deterministic session id), so parallel repos in separate windows are fine. The bug is the in-place workspace switch (Open Project / Open Folder -> kernel.project.open, app.dart:456/474): it only emits ProjectOpened and updates settings (project.dart:147) — nothing tears down Claude sessions.
Two compounding causes:
1. ClaudeSessionOrchestrator.spawn() is idempotent on the literal key ''primary'' WITHOUT checking cwd (session_orchestrator.dart:192-194) — it returns the old repo''s session for the new repo.
2. ClaudePane spawns once behind a _spawned guard, under a GlobalKey host (claude_pane.dart:140, extension.dart:33/52) — so after an in-place switch it never re-resolves; it keeps the previous repo''s session/conversation.
Fix (T-269):
- Orchestrator safety net: spawn() must reuse an existing session only when its cwd matches spec.cwd; a cwd mismatch is a stale cross-workspace binding -> close + spawn fresh.
- ClaudePane (primary) listens for ProjectOpened and rebinds: close its orch entry, null out _sessionId/_repoRoot, respawn for the new repo. Secondaries do not self-rebind.
- ClaudeSessionHost listens for ProjectOpened and drops secondary/fork tabs (the old repo''s), keeping just the primary — a switched workspace starts like a fresh launch.
- Extension tears down all sessions whose cwd != new root on ProjectOpened (catches team/non-pane sessions).', 'done', 'high', NULL, NULL, 'D-77', '2026-06-07 09:44:17', '2026-06-07 09:51:32', NULL, '2cc07f04fac1c3a563e818eac8f8a8e2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4XBRZJ6DEHFWREWC0', 'task', '06FB0TNQM551VZ2NYV6PD0XQRC', 'Edit menu (hat-bar menu bar) — routed to focused surface', 'Deferred from T-48 v1. Adds the Edit top-level menu to the hat-bar application menu (T-48). Items (undo, redo, cut, copy, paste, select-all, find/replace) reference registered command ids and route to the focused surface; inapplicable items render disabled (greyed), per T-48''s show-but-disable context behavior. Depends on focused-surface command routing so the menu reflects the active editor/pane. Reuses T-48''s menu widgets, command->item mapping, and keyboard/a11y model.', 'backlog', 'medium', NULL, NULL, 'D-7', '2026-06-07 10:42:32', '2026-06-07 10:42:32', NULL, '6f53d58d5090926757e63498d3a7d96d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM67PDXAFTPPW7B1WDR', 'task', '06FB0TNQM551VZ2NYV6PD0XQRC', 'Selection menu (hat-bar menu bar) — routed to focused surface', 'Deferred from T-48 v1. Adds the Selection top-level menu to the hat-bar application menu (T-48). Selection operations reference registered command ids and route to the focused surface; inapplicable items render disabled per T-48''s show-but-disable context behavior. Depends on focused-surface command routing. Reuses T-48''s menu widgets, command->item mapping, and keyboard/a11y model.', 'backlog', 'medium', NULL, NULL, 'D-7', '2026-06-07 10:42:38', '2026-06-07 10:42:38', NULL, '5b1ac6bba233c1b0c30c1947dd023377', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM40JE9MZZWCXC91THG', 'story', NULL, 'make sidebar filter boxes addressable via MessageBus (clide ui filter)', NULL, 'done', 'medium', NULL, NULL, 'D-6', '2026-06-07 10:23:13', '2026-06-07 10:43:18', NULL, '0a09af66fc3d2a61d4cc7837fe86127f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM551VZ2NYV6PD0XQRC', 'story', NULL, 'application menu bar (Zed-style)', 'Add a Zed-style application menu integrated into the hat bar: File, Edit, Selection, View, Help/About. Rendered as custom Flutter widgets (no native menu bar — we own the chrome per D-7). Menus expose the same commands registered in the command palette with their keybindings shown inline. Submenus for View should include panel toggles, zoom, focus mode. About opens a modal with version, license, and links.
## Refinement (decisions, 2026-06-07)
**v1 scope:** File, View, Help/About only. Edit and Selection are deferred to follow-ups [[T-271]] (Edit) and [[T-272]] (Selection) — both blocked on this story, since they need focused-surface command routing.
- View submenu: panel toggles, zoom, focus mode.
- Help: About modal (version, license, links).
**Command mapping (hybrid):** A hand-authored menu tree defines curated placement — ordering, grouping, separators, and which commands sit where. Any registered command not explicitly placed auto-fills from the command registry (by category) into the matching submenu / an overflow section, so newly registered commands surface without manual wiring. Each item''s title + keybinding are pulled from the registry.
**Context behavior:** Items reflect the focused surface; inapplicable items render disabled (greyed), not hidden. Mainly exercised once Edit/Selection land, but View/File items honor it too where relevant.
**Keyboard / a11y:** Full keyboard support — Alt+mnemonic opens a menu, arrow-key navigation within, Enter activates, Esc / click-away closes. Must meet the a11y contract (keyboard nav + semantics).', 'done', 'medium', NULL, NULL, NULL, '2026-04-23 20:30:09', '2026-06-07 15:40:06', NULL, 'c61a7b26026c9727506b5ec163c1af94', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM71P14PAF7K4QE61G0', 'story', '06FB0TNQM7ZGZ9GQ5W1MKV00Y4', 'Merge tool-call + success result into one card with header status check', 'Today a Claude tool call renders as TWO stacked cards in the conversation log: the tool-use card (e.g. "Write <path>" with a collapsible call body) and a separate success/result card ("Write · result / File created successfully..."). Collapse the successful pair into ONE card; failure keeps the current two-card interaction.
Current code: lib/builtin/claude/src/conversation_view.dart — _toolUse (~L294) renders AssistantToolUse, _toolResult (~L329) renders ToolResultMessage; the two ConversationItems are linked by toolUseId (ToolResultMessage.isError marks failure). ConversationCard header is built in lib/builtin/claude/src/conversation_card.dart _header (~L200). Builds on T-168 (per-tool body rendering).
Desired behavior (success):
- One card per successful tool call — the tool-use card. The standalone success result card is suppressed (folded in), not rendered separately.
- A success-green check mark sits at the RIGHT END of the card header row (reuse tokens.statusSuccess + a check glyph: PhosphorIcons.check or CheckIcon). No new theme tokens.
- Collapsed by default: header shows the existing summary + the check. Expanding the card reveals the CALL segment (the input — path / diff / command, as today) and, BELOW it, the RESULT segment (the swallowed output). This applies to ALL tools, not just Write/Edit: for Bash/Read/Grep/LS the real output relocates into the expandable body so nothing is lost; for Write/Edit the trivial confirmation folds in the same way.
- Render the result segment as an inline ClideCodeBlock (lib/widgets/src/clide_code_block.dart) WITH editor syntax colorization. ClideCodeBlock already colorizes via TreeSitterService when handed a language — so the work is inferring/passing the right language per tool: Read → from the file extension/path (language_map.grammarForPath), Bash → bash, Grep/LS/others → text fallback. Where a path is available, prefer it so highlighting matches the editor.
Desired behavior (failure) — UNCHANGED:
- Leave the current interaction as-is: the tool-use card plus the prominent red "· error" result card (borderColor statusError, expanded-by-default). Optionally show a red status mark on the tool-use header for symmetry, but do NOT fold the error into one card.
What to build:
1. ConversationCard: a trailing header status slot (e.g. status: success|error|none) rendered in _header between the Spacer/summary and the hover action buttons.
2. ConversationCard: support a second body segment so an expanded card can show CALL then RESULT with a clear visual separator / sub-label (e.g. a divider or a muted "result" label) so the user can tell the call from its output.
3. conversation_view: a result-by-toolUseId lookup (reverse of the existing toolUseById) so a tool-use card knows its outcome; on a successful pair, stamp the check + fold the result body and SUPPRESS the standalone success ToolResultMessage from the rendered list. Errors render both cards as today.
4. Per-tool result language inference for the folded code block.
Edge cases to handle:
- In-flight tool-use with no result yet: render the call card as today (no check, no folded result); status/result appear once the result arrives.
- Orphan result with no paired tool-use: keep rendering it standalone.
- Permission-resolved tool-use cards (conversation_view.dart ~L297, already green/red bordered + collapsed): reconcile so the merged-card + check treatment is consistent and not duplicated with the existing border-outcome styling.
- Result items may not be strictly adjacent to their tool-use in the list; suppression must be keyed by toolUseId, not list position.
Tests: unit/widget coverage for the merged success card (collapsed shows check; expanded shows call + colorized result), language inference per tool, the unchanged error path, and the in-flight/orphan cases. Add a golden for the merged success card.
Refs: D-78 (interaction zone / display-only conversation widgets). Builds on T-168 (per-tool tool-use/result body rendering).
IMPLEMENTATION NOTES (from streamlining analysis):
C — Error-card symmetry decision: this ticket merges only SUCCESS into the tool card; the error path intentionally stays a separate prominent red "· error" card (conversation_view.dart ~L337) for failure visibility. Decide explicitly: keep that asymmetry (success folds, failure stays two-card) OR also stamp a red status mark on the tool-use header for visual symmetry while still keeping the separate red card. Default leaning: keep the separate red card; optionally add the red header mark.
D — Activity-cluster coupling: activity_cluster.groupConversation currently folds a tool CALL and its RESULT as TWO separate foldable items into a cluster (activity_cluster.dart ~L100-109, classifying each by toolUseId). Once this ticket makes call+result a single self-contained card, the grouping pass must treat the tool call as ONE unit (its result is part of the card, no longer a separate foldable item) or the result will double-render (once folded into the card, once as a cluster item). Update _isFoldable / the pairing logic accordingly and add a test that a merged tool card is not double-counted.', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-07 08:10:42', '2026-06-07 17:41:11', NULL, 'e62becebc27914b830a058e34cf7756e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5W6VN98RQM6S22X28', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Claude bottom status bar often stays empty / doesn''t update', 'The bottom status-bar context slot for the Claude pane (model · permission-mode · context, T-145/T-150) frequently renders empty and doesn''t update. The slot is fed by the active pane''s status widget via the focus service (PaneContextStatusItem -> ClidePane.statusWidget in claude_pane.dart), sourced from StreamJsonSession.statusStream (model/permissionMode from the ''system/init'' event; cost/contextWindow from ''result'' events in stream_json_session.dart).
Repro: open clide; the status line is often blank and stays blank until/unless a turn completes (or never populates).
Likely suspects to investigate:
- status only published while the pane is the focused contribution (active==true) — if focus isn''t on the Claude pane, the slot clears.
- statusStream may not emit until the first ''result''/''init'' event; a resumed session (--resume) may not re-emit init, so model/mode never arrive.
- _statusWidget returns null when _status.isEmpty AND no skills, so an unstarted/!init session shows nothing.
- focus-slot wiring (FocusTracker) may not re-publish on pane (re)build / session rebind.
Acceptance: the Claude status line shows model · mode · context promptly after a session starts/resumes and stays current across turns and focus changes; add a test covering the resumed-session (no fresh init) case.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-08 07:26:13', '2026-06-08 07:46:46', NULL, 'd5c2cfe56ffde42485efa9eb596c0610', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5TWC00GW0P3X02HZW', 'epic', NULL, 'UI tweaks & fixes', 'Ongoing umbrella for small, standalone UI polish, cosmetic tweaks, and visual/interaction bug fixes that don''t belong to a feature epic — color/token corrections, control placement, status surfaces, micro-interactions, and the wireframes that frame them. Children are independently shippable; the epic stays open as a rolling home for this class of work.
**PERMANENT — never close.** This is a standing rolling tracker for loose UI/UX work and bugs, not a deliverable epic. It stays open indefinitely; only its children are completed/closed. Do not mark T-276 done even when all current children are closed — new tweaks/fixes get filed here on an ongoing basis.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-08 07:46:23', '2026-06-08 07:53:32', NULL, '61e4e273c9a2f876ba02b8ba3299c90e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM60PAS5RJ55CZGPM1G', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Surface pql labels in the clide tickets UI (render + label navigator)', 'clide doesn''t surface pql ticket labels, so a rolling ''UI tweaks'' tracker has to be modeled as an epic (T-276) instead of a label — which is the cleaner primitive. Make labels first-class in the tickets UI so a label can BE the tracker.
Scope:
- Render a ticket''s labels on its card and detail view in the tickets panel (lib/builtin/tickets) — small chips/pills, ui-design tokens, no hardcoded hex.
- A label navigator: filter/group the board by label (a sidepane affordance or a filter control on the tickets panel) so ''show me everything labelled ui-tweak'' is one click. Reuse the existing filter-box / MessageBus addressability (T-270) where it fits.
- Read/write labels via the pql wrapper only (D-3 — wrap, don''t duplicate); pql already supports the ''ticket label'' verb and exposes labels on records.
- CLI/UI parity (D-6): label add/remove + filter should each have a clide verb counterpart.
Once this lands, loose UI/UX work + bugs can move from the T-276 tracker-epic to a label (e.g. ''ui-tweak''); file that migration as a follow-up — do NOT auto-close T-276 (it''s marked never-close).
Relevant: lib/builtin/tickets/, lib/src/pql/ (pql wrapper), and pql''s ''ticket label'' / ''ticket list'' label fields.', 'backlog', 'medium', NULL, NULL, 'D-3', '2026-06-08 08:32:30', '2026-06-08 08:34:47', NULL, 'f5b713e5222e00e1a8074a0abead336b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7XXSERDHKR0EEH3N4', 'story', '06FB0TNQM7ZGZ9GQ5W1MKV00Y4', 'Fold sub-agent prompt into the Agent card; relabel from you to agent prompt', 'When Claude launches a sub-agent (the Agent/Task tool), the sub-agent conversation runs as a SIDECHAIN. The prompt Claude wrote for that sub-agent comes through the transcript as a UserMessage with isSidechain=true but injected=false. The conversation view does not look at isSidechain, so it renders that prompt with the blue "you" label exactly like real user input — FALSELY implying the user sent it. It sits as a standalone block below the Agent tool-use card. This is misleading: the user did not write that prompt.
Confirmed facts from the code: every transcript envelope carries BOTH isSidechain AND parentUuid (transcript_reader.dart ~L547 parses isSidechain but never USES it; parentUuid is dropped entirely). The Agent/Task tool-use is rendered by _toolUse in lib/builtin/claude/src/conversation_view.dart (only AskUserQuestion is special-cased today). The "you" vs "context" label is chosen in conversation_view.dart (~L200): UserMessage with injected=true → "context" (muted); else → "you" (blue, tokens.globalFocus). isSidechain is currently never read by any rendering/grouping logic.
Design (confirmed with user):
1. Never label a sidechain prompt "you". A UserMessage with isSidechain=true is an AGENT PROMPT, not user input — label it "agent prompt" with muted / de-emphasized styling (same precedent as injected "context" messages, D-78). Never the blue "you" treatment.
2. Fold the prompt INTO the Agent tool-use card above it, COLLAPSED BY DEFAULT. The Agent card becomes the container that owns its prompt: expand the card to reveal the prompt; collapsed (default) it is hidden. The standalone prompt block is SUPPRESSED from the list — same fold-a-standalone-item-into-its-card pattern as T-262.
3. Prompt folds into the CALL, not the results. Only the prompt folds into the collapsed Agent call card. The sub-agent work (its thinking / tool calls / results) stays VISIBLE as the results chain after the call — not buried. Shape: [collapsed Agent call, owning the prompt] then [the run results], with no misleading "you" block anywhere. Agent prompts are valuable enough to keep with the call rather than fold into the subsequent results chain.
4. Link the prompt to the RIGHT Agent card via parentUuid. Parse parentUuid (currently dropped) so each sidechain prompt attaches to its spawning Agent tool-use. This matters when several agents run in parallel in one turn — a positional "nearest preceding Agent" heuristic would misattach; the parent link is the robust route. Heuristic only as a fallback when the link cannot be resolved.
Touch points:
- transcript_reader.dart: capture parentUuid from the envelope and expose it on ConversationItem (it is present in the JSONL, just not parsed).
- conversation_view.dart: relabel sidechain UserMessage to "agent prompt" (stop the "you" path); fold it into the Agent card body; special-case the Agent/Task tool name (today only AskUserQuestion is special-cased); suppress the standalone block.
- activity_cluster.dart: ensure the suppressed prompt is not double-counted in grouping; leave the sidechain results in the chain.
Edge cases:
- Parallel agents in one turn → each prompt attaches to its own card (the reason for parentUuid).
- Orphan prompt (no resolvable parent) → still render as "agent prompt", muted/collapsed, never "you".
- Nested / background sub-agents.
Tests: unit/widget coverage for sidechain-prompt relabel (never "you"), folding into the Agent card (collapsed hides, expand reveals), suppression of the standalone block, parentUuid attachment with parallel agents, and the orphan fallback. Add a golden for the Agent card with a folded prompt.
Refs: D-78 (interaction zone / display-only conversation widgets; injected/context muting precedent). Related: T-262 (fold-standalone-item-into-its-card pattern).
IMPLEMENTATION NOTE (E — Agent-card layered ordering, from streamlining analysis):
With this ticket (fold prompt) and T-262 (merge success result) both landing, the Agent/Task card ends up owning multiple segments: the tool INPUT, the folded PROMPT, and — via T-262 — the sub-agents final returned RESULT (the Task ToolResultMessage). Define a deliberate layered order when expanded (e.g. call/input → prompt → returned result) with clear sub-labels/dividers so the Agent card stays readable and does not become a kitchen sink. Coordinate with T-262 (result merge) and T-264 (nesting the whole run): the nested run region vs the returned-result segment must not duplicate the sub-agent output.', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-07 08:34:02', '2026-06-08 08:45:23', NULL, 'ee9a4aa047db942114d25f30afdfc7e3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM45HHRBCRMV2166Y6M', 'story', '06FB0TNQM7ZGZ9GQ5W1MKV00Y4', 'Restyle activity/holder card as a container of sub-cards (fix collapse-control scroll race)', 'Restyle the activity card (and, via a shared primitive, the nested sub-agent run from T-264) so a folded run reads as ONE container that holds its sub-cards, with a reachable collapse/expand affordance that survives live writes.
DESIGN RESOLVED (with user) — supersedes the earlier design-pending note.
1. HOLDER = a shared container primitive. Extract one container widget (e.g. a ClideHolderCard / conversation-level container) that renders a titled/attributed frame WRAPPING its child sub-cards. Both the activity card (_ActivityCard, lib/builtin/claude/src/conversation_view.dart ~L427) and the nested sub-agent run (T-264) consume it, so the container model is settled once. NOTE: this makes T-266 a DEPENDENCY of T-264 (the shared primitive must exist before the agent-run nesting can consume it) — update epic T-267 sequencing: T-266''s primitive lands before/with T-264, no longer strictly last.
2. Toggle = the HOLDER BACKGROUND, not the children. Clicking the container''s own background/chrome (its padding, the gaps between sub-cards, its gutter — any region NOT covered by a child) toggles collapse/expand. Crucially:
- Taps on a child sub-card lying on top of the background MUST NOT toggle the holder. Child cards opaquely consume their own hit region across their FULL bounds (including their body), so a click on a sub-card interacts with that card, never the holder.
- The copy button (and any child control / ClideTappable) keeps its own interaction — never swallowed by the holder toggle.
- Implementation: a background gesture target behind the children that only fires for hits the children do not consume (HitTestBehavior; children opaque over their bounds). No whole-card overlay that would intercept child taps.
3. Autoscroll stays ALWAYS-FOLLOW. Keep the current tail-follow (conversation_view._onChanged ~L119 jumps to maxScrollExtent on every change). Reachability of the collapse control no longer depends on getting to a top header: because the toggle is the ever-present holder BACKGROUND, a click on whatever background area is currently in view (near the latest content while following) collapses the holder — ending the race. Once collapsed the card shrinks to its ticker, so the scroll-yank stops.
4. Keep the collapsed ticker. Collapsed (default) still shows the one-line live ticker (latest step) + step count, as today. Expanded shows the contained sub-cards.
5. Accessibility (obligation): a background tap is not keyboard/AT reachable on its own, so keep an EXPLICIT focusable, Enter/Space-activatable collapse control (e.g. the ticker row remains a Semantics button / a focusable caret) in addition to the background-click affordance. Announce expanded/collapsed + step count (as _ActivityCard does today).
Touch points:
- New shared container primitive (extracted from _ActivityCard).
- conversation_view.dart: _ActivityCard adopts the primitive; background-toggle wiring; ensure child ConversationCards opaquely consume their bounds so they do not bubble taps to the holder.
- T-264 consumes the same primitive for the nested agent run.
Edge cases:
- A holder whose child is itself collapsible (a tool card): tapping the child''s caret/body toggles the CHILD, never the holder.
- Text selection inside a child (ClideSelectionArea wraps the list): selection drags on child bodies must not be hijacked by the holder toggle — another reason the holder responds only to its own background, and only to taps (not drags).
- Empty / single-item holder.
Tests: holder background tap toggles; tap on a child sub-card does NOT toggle the holder; copy button still copies (not swallowed); keyboard/AT path toggles via the explicit control; collapsed ticker + step count preserved. Add a golden for the holder container (collapsed + expanded).
Refs: D-78. Provides the shared container consumed by T-264. Related: T-230 (activity card), T-262 / T-263 / T-265. Parent: T-267.', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-07 08:41:13', '2026-06-08 09:04:12', NULL, '3cb18c97d0640b5f61c494d258aa3ef2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5YYMMKFD5Z8WG9F60', 'story', '06FB0TNQM7ZGZ9GQ5W1MKV00Y4', 'Nest the whole sub-agent run under its Agent card (attribute sidechain via parentUuid)', 'Completion of T-263. T-263 folds the sub-agent PROMPT into the Agent card, but the sub-agent run itself — its prose, thinking, and tool calls — still renders inline in the MAIN conversation chain. A reader cannot tell where the sub-agent run ends and the main thread resumes; sidechain items are visually indistinguishable from main-thread items.
Root cause: the conversation is a flat list of ConversationItems rendered by _ConversationTurn (lib/builtin/claude/src/conversation_view.dart build ~L130, _ConversationTurn ~L182). isSidechain exists on every item (transcript_reader.dart ~L46) but no rendering/grouping logic reads it. activity_cluster.dart groups consecutive meta runs but is sidechain-agnostic.
Design: use the parentUuid link (parsed in T-263) to group an ENTIRE sidechain run as a nested region OWNED by its spawning Agent card. The run renders as a distinct, attributed, collapsible region (indent / attribution band / nested holder) under the Agent call rather than as loose cards in the main chain. Per the user rule from T-263, the run results stay VISIBLE (not folded away) — this is about attribution and containment, not hiding.
Scope:
- Build a sidechain-aware grouping pass (extend or complement activity_cluster.groupConversation) that collects all items sharing a sidechain ancestry (via parentUuid chain back to the Agent tool_use) into one nested group.
- Render that group as a nested region under the Agent card (visual indent / attribution), keeping the sub-agent results first-class within it.
- Handle parallel agents (each run nests under its own card), nested sub-agents, and background agents.
- Orphan sidechain items (no resolvable parent) degrade to the current inline rendering but still attributed (see T-264/relabel work).
Depends on T-263 (parentUuid parsing + Agent card as container). Pairs with the sidechain-prose relabel ticket. Note the holder-card restyle + auto-scroll race is a separate design discussion (see the holder-card ticket).
Refs: D-78. Builds on T-263. Related: T-262, T-230 (activity card grouping).', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-07 08:40:25', '2026-06-08 09:25:50', NULL, '44e706a4dbbffb4d53ceef9236d38955', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4Q1Q97CHA0XNK2G4W', 'story', '06FB0TNQM7ZGZ9GQ5W1MKV00Y4', 'Relabel sidechain assistant prose/thinking — attribute to agent, not claude', 'Mirror of T-263 on the OUTPUT side. A sub-agent runs as a sidechain; its assistant prose renders with the label "claude" and its thinking with "thinking", identically to the MAIN assistant — because the label logic does not check isSidechain. So a sub-agents output is presented as if the main Claude said it. Same misattribution T-263 fixes for the prompt (the "you" problem), mirrored on the assistant side.
Current code (lib/builtin/claude/src/conversation_view.dart _ConversationTurn build ~L221): AssistantTextMessage → ConversationCard(accent: claudeAccent, label: "claude"); AssistantThinkingMessage (~L227) → label "thinking". Neither inspects ConversationItem.isSidechain (transcript_reader.dart ~L46), which is present but unused.
Design: when isSidechain is true, attribute the assistant items to the AGENT rather than the main Claude — e.g. label "agent" (or the agent/sub-agent name where resolvable) with de-emphasised styling, NOT the coral "claude" brand accent (claudeAccent is the main-thread brand, reserved for the real Claude). Thinking inside a sidechain attributed/muted likewise.
Scope:
- Branch the AssistantTextMessage / AssistantThinkingMessage rendering on isSidechain.
- Pick the sidechain label + styling (distinct from "claude"; reuse muted tokens, not claudeAccent).
- Keep behavior identical for main-thread items.
Tests: relabel of sidechain assistant prose/thinking (never "claude"/coral); main-thread unchanged; golden for an attributed agent prose card.
Pairs with the nest-the-agent-run ticket (attribution + containment) and T-263 (prompt side). Refs: D-78.', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-07 08:40:33', '2026-06-08 10:09:14', NULL, '161a4dd3976932afe0b038539d846c86', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7ZGZ9GQ5W1MKV00Y4', 'epic', NULL, 'Conversation rendering streamlining — fold, attribute, contain', 'Home for the cohesive set of work that streamlines how the Claude conversation log renders, so a heavy agent turn reads clearly and nothing is mislabeled or duplicated. Three recurring moves bind these tickets: (1) FOLD a redundant standalone item into its owning card (success result → tool card; agent prompt → agent card); (2) fix MISLEADING ATTRIBUTION (sub-agent prompt shown as "you"; sub-agent prose shown as "claude"); (3) settle the CONTAINER / interaction model (holder card that contains sub-cards, nested agent run, autoscroll-vs-reach-the-control).
Common substrate across the children: the flat ConversationItem list and its renderers in lib/builtin/claude/src/conversation_view.dart, the pure grouping pass in activity_cluster.dart, the ConversationCard template in conversation_card.dart, and the so-far-unused ConversationItem.isSidechain / parentUuid transcript fields (transcript_reader.dart).
Children: T-262 (merge tool-call + success result into one card with header status check), T-263 (fold sub-agent prompt into the Agent card; relabel you → agent prompt), T-264 (nest the whole sub-agent run under its Agent card via parentUuid), T-265 (relabel sidechain assistant prose/thinking — agent, not claude), T-266 (restyle activity/holder card as a container of sub-cards + fix the collapse-control scroll race). Sequencing: T-262/T-263 first, T-264/T-265 build the sidechain story, T-266 settles the shared container model last.
Refs: D-78 (interaction zone / display-only conversation widgets). Built on T-168 (per-tool body rendering) and T-230 (activity card).
SEQUENCING UPDATE (after T-266 refinement): the shared container/holder primitive now lives in T-266 and is CONSUMED by T-264 (nested agent run), so T-266 is no longer "last" — its primitive lands before/with T-264. T-264 is now blocked by T-266. Revised order: T-262 / T-263 (fold success result, fold agent prompt) → T-266 (shared holder/container primitive + activity-card restyle) → T-264 (nest the whole agent run on that primitive) → T-265 (relabel sidechain prose) can land anytime alongside.', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-07 08:42:53', '2026-06-08 10:09:21', NULL, 'bde40f65dd3bb674325ecfaca7e9befa', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM55CV3VDYS88VBJZ60', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'turn indicator font color → Claude accent orange', 'Change the animated running/turn indicator''s text color to the Claude accent orange (the brand accent), instead of its current color. Lives in lib/builtin/claude/src/running_indicator.dart (the rotating-verb turn indicator, T-255). Use the appropriate accent token from SurfaceTokens (ui-design skill: confirm which token maps to Claude accent orange) — no hardcoded hex. Update/adjust any golden or widget test that asserts the indicator color.', 'done', 'low', NULL, NULL, NULL, '2026-06-07 17:39:10', '2026-06-08 11:44:27', NULL, 'cf11d45286b6475ce98a771026d8b53f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM59DG2WADJ9A0JFD1W', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Clearer parent/child nesting in the tickets panel (tree layout, bold focused ticket)', 'The tickets panel shows a ticket''s parent inline as ''T-273 ← T-276'', which reads ambiguously (which one am I looking at? what''s the direction of the arrow?).
Replace it with a small tree/indented layout that makes the hierarchy obvious, e.g.:
T-276
└ T-273
Requirements:
- Parent on top, the current/child ticket indented below with a tree connector (└ / ├ style box-drawing or an equivalent indent + guide).
- The ticket the user is actually looking at (the focused/opened one) is bold; ancestors are normal weight (and ideally muted/secondary).
- Support more than one level if a chain exists (initiative → epic → story → task), indenting each level; at minimum handle the common 2-level parent→child case.
- Make each id in the chain clickable to navigate to that ticket (ties into the reader/board navigation; relates to T-279 cross-ref linking).
- ui-design tokens for the guide lines + bold/muted weights; no hardcoded hex.
Scope: tickets panel rendering — lib/builtin/tickets/. See attached screenshot for the current ''T-273 ← T-276'' card style being replaced.', 'done', 'medium', NULL, NULL, NULL, '2026-06-08 11:46:11', '2026-06-08 11:49:36', NULL, 'bf04e28befe771ec1a55fc44108cabb6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6K1QRC911JMQ9C960', 'bug', '06FB0TNQM5HM3N07VB3RPFJCFM', 'Resume hang has no timeout/fallback; resume decided by file-exists not content', 'Surfaced 2026-06-08 while chasing a --resume hang in the Claude pane (T-274 diagnostic line). The specific corrupted-transcript repro may turn out to be a one-off, but the code trace found two real latent gaps that make a resume hang unrecoverable regardless of root cause.
(a) No timeout / no fallback on the init-event wait.
On spawn the orchestrator listens for the session_id from claude''s init event (StreamJsonSession sessionIdResolved -> session_orchestrator.dart ~257) with NO timeout. If ''claude --resume <id>'' never emits an init event (hangs), the listener never fires: the pane shows ''resumed · <id>'' + running indicator forever, the process is never killed, and there is no fallback to a fresh --session-id spawn. Unrecoverable without killing the process / restarting clide.
(b) Resume is decided by file existence, not resumable content.
claude_pane.dart ~279: ''final resume = await File(transcriptFile).exists();'' — resume=true purely because the .jsonl exists. A metadata-only transcript (only system / permission-mode / attachment records; parseTranscriptChunk().items returns []) yields resume=true with zero seeded items. So clide passes --resume against a functionally empty session, the diagnostic logs ''fresh session (no history)'' (it keys off seeded count, not the resume flag — itself misleading), and if that resume hangs there is no fallback per (a).
Acceptance:
1. If a --resume spawn yields no init event within a short timeout (e.g. 10-30s), fall back to a fresh --session-id spawn (or surface a recoverable error with a retry affordance) — the pane must never spin forever with no recovery.
2. Don''t pass --resume for a transcript that has no resumable conversation items: validate parsed item count (not just file existence) before choosing --resume vs --session-id, and/or detect-and-repair a metadata-only transcript.
3. The T-274 diagnostic log reflects the actual spawn mode (resume vs fresh), not just seeded-item count.
4. Tests: (i) fake process that never emits init -> pane falls back / surfaces error within the timeout; (ii) metadata-only transcript -> spawn chooses fresh, not --resume.
Cross-refs: T-274 (resumed-session status bar empty), T-167/T-185 (resume/fork session id capture), D-77, claude_pane.dart:279/300-308, session_orchestrator.dart:240/257.
UPDATE 2026-06-08: the active hang did NOT reproduce — clide is running fine inside the 31b214bd primary session (this very session resumes cleanly). So the original break was a one-off (likely the single corrupted transcript), not a live resume bug. This ticket stands as defensive hardening only: the two gaps (no init-event timeout/fallback; resume keyed off file-exists not content) are real but latent — they''d only bite again if a resume genuinely stalls or a metadata-only transcript appears. Lowering to low priority.', 'backlog', 'medium', NULL, NULL, 'D-77', '2026-06-08 13:39:51', '2026-06-08 13:44:57', NULL, '1b3f27ac668b052d30b95ec8d98e38b4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6A6A56VFSVRRBKMP0', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Clickable T/D/Q/R cross-refs in the Claude conversation view → open in context-pane readers', 'In the Claude conversation view, linkify governance/ticket references so they''re clickable and open the matching record in the correct context-pane reader.
Patterns to detect in rendered message text: T-NNN (pql tickets), D-NNN (decisions), Q-NNN (open questions), R-NNN (rejected alternatives).
Behaviour:
- Render each match as an inline link (ui-design tokens, link affordance consistent with other conversation links; do not linkify inside code spans/blocks).
- On click, open the record in the proper context-pane reader:
- T-NNN -> tickets reader/panel (the ticket detail).
- D-NNN / Q-NNN / R-NNN -> decisions reader, scrolled to the D/Q/R record under governance/decisions, /questions, /rejected.
- Reuse the existing reader-open plumbing rather than inventing new routing: ReaderNav / the ui.open verb / MessageBus reader addressing (T-186, T-231, T-233) and the decisions + tickets panels.
- D-6 parity: opening a record by id should already (or also) be reachable via a clide verb.
Scope notes:
- Lives in the conversation rendering path (lib/builtin/claude/src/conversation_view.dart + conversation_card.dart + the markdown/text renderer it uses). Relates to the conversation-rendering epic T-267 — could move there if preferred.
- Resolve id -> source: tickets via the pql wrapper (lib/src/pql), governance records via the decisions index/reader.
- Handle unknown/missing ids gracefully (render as plain text or a dead-link tooltip, no crash).
Tests: linkifier unit tests (matches T/D/Q/R, ignores code spans, ignores bare words like ''T-shirt''); a widget/integration test that clicking a ref dispatches the reader-open.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-08 10:21:20', '2026-06-08 14:12:46', NULL, 'bde704ef6fbb232fbe17ce5d26c3998c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4KFHAS1WZFGBJ6JN4', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'ClideMarquee ignores MediaQuery.disableAnimations (reduced-motion + pumpAndSettle hang)', 'Two animation mechanisms exist and are inconsistent. The turn/running indicator (running_indicator.dart, T-273) gates its AnimationController on MediaQuery.disableAnimations and stops when reduced-motion is set. ClideMarquee (clide_marquee.dart, the status-bar footer scroll, T-150/T-160) drives a raw Ticker whenever its content overflows, with NO disableAnimations check and no flag.
Two costs:
1. Accessibility — a reduced-motion user still gets the scrolling footer; the marquee ignores the OS reduce-motion setting the turn indicator honors.
2. Testing — the marquee''s perpetual Ticker is the historical pumpAndSettle-hang culprit; its own tests cope by only ever calling pump()/pump(Duration), never pumpAndSettle.
Fix: unify on the one mechanism — make ClideMarquee honor MediaQuery.disableAnimations. When reduced motion is set, don''t start the ticker (stop + reset offset to 0 if running), and render the child statically (clipped) instead of looping. Read the flag in didChangeDependencies like running_indicator does. Then both animations obey one flag, the marquee becomes reduced-motion-correct, and it stops being a pumpAndSettle landmine.
Acceptance:
1. With MediaQuery(disableAnimations: true), an overflowing ClideMarquee does not scroll and pumpAndSettle completes (no hang).
2. With the flag off, overflow still scrolls as today.
3. Toggling the flag at runtime starts/stops the scroll.
4. Test covering the reduced-motion (no-scroll, settles) case.', 'done', 'medium', NULL, NULL, NULL, '2026-06-08 14:55:43', '2026-06-08 14:57:15', NULL, '97144d20a2edf1911ee298421699fcc8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6GZ55VD4SAZZ6XXP4', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Conversation ListView items lack keys — card state mis-associates when results stream in', 'The conversation ListView.builder (conversation_view.dart:257) builds its items (_ConversationTurn, _ActivityCard) with NO keys. Each resolves to a stateful subtree (ConversationCard: _collapsed/_hover/focus nodes, with _collapsed set once and never re-synced in didUpdateWidget; ClideHolderCard owns cluster expand state). In a keyless list Flutter matches State to widgets by POSITION, so when the visible list reshapes, State attaches to the wrong logical card.
The list reshapes exactly when a read/write result lands: T-262 folds a successful result into its call card and suppresses the standalone result via _visibleItems, errors append a sticky card, and activity clusters re-fold. So after a tool completes, collapse/hover state — and for a folded cluster, which tools/summary it shows — can jump to the wrong card. (The success/error mark itself is widget.status, recomputed per build, so it follows the widget; the stateful bits are what mis-associate.)
Fix: give each ListView item a stable ValueKey from its identity — sticky item by item.uuid, folded cluster by its first item''s uuid (namespaced so sticky vs cluster can''t collide) — plus add super.key to the _ConversationTurn/_ActivityCard constructors. Also key the nested run items in the Agent holder + activity card so sub-agent streams reshape safely.
Acceptance:
1. Streaming a tool-use then its success result keeps each card''s collapse state pinned to the right card (a card the user expanded stays expanded when a later tool completes).
2. A folded activity cluster keeps its identity/summary across reshapes.
3. Widget test: pump a conversation, toggle one card''s collapse, stream a new result that reshapes the list, assert the toggled card is still the one expanded.', 'done', 'high', NULL, NULL, NULL, '2026-06-08 15:22:32', '2026-06-08 15:37:29', NULL, 'fd360d075436a0459508bd994828b39a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7W2MWZ45YNK7TJGC4', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Collapsed conversation card has inconsistent bottom padding', 'The collapsed Agent (tool-call) card in the Claude conversation view has different padding beneath it than the spacing around all other cards — see the gap flagged below the `> Agent` disclosure in the conversation. Make the collapsed card''s bottom padding match the standard inter-card spacing used everywhere else.
Card layout lives in lib/builtin/claude/src/conversation_card.dart: the outer wrapper applies EdgeInsets.only(bottom: 14) (line ~188) and the stripe variant adds inner EdgeInsets.fromLTRB(12, 8, 12, 8) (line ~210). When collapsed, only the header renders, so the combined bottom spacing (inner 8 + outer 14) reads differently from expanded cards. Reconcile so the visible gap below a collapsed card equals the gap below other cards.', 'done', 'low', NULL, NULL, NULL, '2026-06-08 13:39:15', '2026-06-08 15:42:01', NULL, '92ea10391ed6be62981f7f4117381d6f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5AAA9WW54JGP83HGW', 'task', '06FB0TNQM5R93N5J6RNVTBRB28', 'Frame0 design: permission-mode dropdown beside the Claude composer', 'Produce a Frame0 wireframe for a persistent permission-mode control next to the Claude conversation composer, then use it to drive implementation.
Goal: surface the current permission mode (default · accept-edits · plan; bypass behind the cockpit guard) AT ALL TIMES, not only in the bottom status bar. Move/duplicate the mode affordance to an icon button sitting to the RIGHT of the composer text box (ClaudeComposer), showing the current mode via icon (+ short label), and opening a dropdown/popover menu to switch mode on click.
Design to wireframe (frame0-wireframe skill):
- Composer row with the mode icon-button anchored at its right edge (trailing), aligned with the send/interrupt control; show resting state for each mode (distinct icon/color per mode) and the open-dropdown state listing selectable modes with the active one marked.
- Account for the interaction zone (D-78): when a permission/AskUserQuestion prompt replaces the composer, define what happens to the mode button (hidden? disabled? persists above?).
- Keep it consistent with the existing status-bar mode badge (T-226: click or Ctrl/Cmd+M cycles the safe trio) — decide whether the status-bar badge stays, is removed, or mirrors the new control.
- Token/geometry per the ui-design skill (no hardcoded hex; trailing-control geometry like the composer''s existing buttons).
Deliverable: a Frame0 wireframe (resting + open states, per-mode variants) exported, plus a short note on the chosen prompt-mode interaction, ready to implement. Relevant code: lib/builtin/claude/src/claude_composer.dart, claude_pane.dart (_ModeBadge / _statusWidget / _cycleMode), claude_status.dart (nextSafePermissionMode / permissionModeLabel).', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-08 07:28:34', '2026-06-08 16:52:13', NULL, 'dfbe1e3e3ea200d993d4e860da4d6769', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6NNRR61E8P6GHXABG', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Right-click context menus with context-aware actions', 'Add right-click (secondary-tap / context-menu key) handling across clide surfaces, opening a context menu anchored at the pointer.
**Build on existing primitives.** Reuse `ClideAnchoredOverlay` + `ClideMenu` (D-88, landed via T-286/T-288) for the popup — do not introduce a new menu widget. The context menu is just another content component over the shared anchored-overlay base, opened on secondary tap instead of a click target.
**Common actions (the usual suspects).** A baseline set available wherever they apply: Copy, Cut, Paste, Select All. These are driven by focus/selection state of the surface under the pointer (editor, text field, terminal selection, list item label).
**Context-aware injected actions.** On top of the baseline, each surface contributes extra menu items relevant to *what* was clicked:
- **File navigator** — clicking a file: Duplicate, Rename, Delete, Reveal, Copy path; clicking a folder: New file/folder, etc.
- **Git pane** — clicking a staged file: Unstage, Revert/Discard; clicking an unstaged file: Stage, Discard changes; clicking a hunk where applicable.
- Other panes contribute their own as the pattern proves out.
**Architecture.** Define a small contract so a surface can declare its context-menu items for a given target (the right-clicked element + its state), which get merged with the baseline edit actions. Honour user/Claude parity (D-6): each injected action should map to an existing `clide` verb / command where one exists, so the menu is a discoverable front-end to commands Claude can also invoke.
**Scope notes.** Start with file navigator + git pane as the two concrete consumers (they motivate the feature). Keyboard parity: context-menu key + Shift+F10 should open the same menu for the focused element. Accessibility: menu items reachable and labelled per the a11y contract.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-08 20:16:22', '2026-06-08 20:16:22', NULL, 'cc6e2f2b7a787cc55b876ce031d7a5f4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4501EBPDFDH84RJ5G', 'task', '06FB0TNQM5R93N5J6RNVTBRB28', 'Migrate remaining anchored surfaces onto ClideAnchoredOverlay (theme picker, typeaheads, quick-open)', 'Follow-up to the D-88 sweep. The primitive (ClideAnchoredOverlay + ClideMenu) shipped and the menu bar (T-286) + T-275 picker are migrated. The theme-picker migration was attempted and REVERTED after hitting two real blockers that affect all the remaining anchored surfaces; resolve these first, then migrate theme picker, @-mention typeahead, slash typeahead, and quick-open.
Blockers found (theme picker attempt):
1. Focus-capture race. ClideAnchoredOverlay.captureFocus does a post-frame _scope.requestFocus(); ClideMenu also autofocuses its own node. Depending on mount timing (e.g. an extra ListenableBuilder wrapper) the scope wins and STEALS focus from ClideMenu''s node, so arrow/enter never reach the menu (Esc still works only via the ancestor fallback). Menu bar happens to win the race; the theme popover loses. Fix the primitive''s focus model so content''s own focus node reliably ends up focused (e.g. don''t force scope focus; or make the scope delegate to the autofocus child deterministically), then re-verify menu bar + anchored tests.
2. Follower content is not reliably mouse-tappable in the widget-test harness. The shared harness() uses Overlay(canSizeOverlay) + a zero-size MediaQuery, so an anchored follower positions the panel off-screen (observed tap offsets like (680,-45) and (680,654)) and tester.tap misses. Also autoFlip reads MediaQuery.size (zero in harness) so it can''t flip. Mitigations: have autoFlip use View.physicalSize (more correct anyway); position content without the inner Align (the panel shrink-wraps); and in tests drive selection via keyboard (as menu_bar does) or provide a real viewport. Consider a test helper for anchored-overlay content.
Then migrate, one surface per commit, keeping each existing test green (theme_picker widget_test, team_chat_sidebar_test, claude_composer_test, quick_open_overlay_test). Quick-open is centered (no follower) and the weakest-fit — it only needs the lifecycle wrapper; migrate last or skip.
Refs: D-88, T-286, T-275 (done), menu bar (done, commit e1d51eb).
RE-SCOPE (2026-06-08, per D-88 amendment): the goal is NOT ''migrate everything onto ClideMenu''. The shared base is ClideAnchoredOverlay; content matches the surface:
- ClideMenu stays scoped to selectable-list menus (menu bar + permission picker — done).
- Add a NEW shared content component ClideTypeahead for the slash + @ typeaheads (near-duplicate caret-anchored completion surfaces) — they share this, not ClideMenu. This dedups two hand-rolls.
- Quick-open keeps bespoke content (centred filter + fuzzy + two-column rows); uses ClideAnchoredOverlay base only. Weakest fit; last or skip.
- Theme picker: its own small toggle+list content on the base (or ClideMenu if it cleanly fits).
Order: (1) fix the base blockers — focus capture racing content autofocus, and follower content untappable in the canSizeOverlay/zero-MediaQuery test harness (use View size for autoFlip; add a test path for anchored content) — with tests; THEN (2) ClideTypeahead + migrate slash + @; (3) theme picker; (4) quick-open last.
DONE: Sweep complete. Base blockers fixed (View-size autoFlip; follower drops Align so it hit-tests; tests use anchoredHarness). Migrated: ClideTypeahead + slash + @-mention typeaheads (live ValueNotifier-bridged suggestions); theme picker onto ClideMenu (HC toggle = keepOpenOnSelect item). Quick-open deliberately NOT migrated — persistent centred widget sharing neither ClideMenu nor anchoring; rationale in D-88 closing amendment.', 'done', 'medium', NULL, NULL, NULL, '2026-06-08 17:52:51', '2026-06-09 06:19:01', NULL, 'c67bf2dd6b082be4de1f53b2f89b4bb3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5R93N5J6RNVTBRB28', 'epic', '06FB0TNQM5TWC00GW0P3X02HZW', 'Anchored popover + menu primitive; migrate anchored surfaces', 'Extract a clide-owned anchored-popover + menu widget primitive (lib/widgets/, no Material) and migrate every anchored surface onto it. Nine surfaces hand-roll the same anchored-overlay + row-list + barrier + keyboard-nav pattern. Two layers: ClideAnchoredOverlay (positioning/lifecycle: LayerLink/follower or centered, barrier, Esc, focus capture, autoFlip) + ClideMenu / ClideMenuListController (rows + reusable nav). Modal DialogRouter pickers (session/project/branch) stay modal. Built on T-275''s permission-mode picker first, then migrate menu bar, theme picker, @-mention, slash typeahead, quick-open. See decision (architecture domain) + plan. Children: primitive, T-275 picker, one per migration.
DONE: Primitive (ClideAnchoredOverlay + ClideMenu + ClideMenuListController + ClideTypeahead) shipped and exported. Migrated: T-275 permission picker, menu bar, theme picker (ClideMenu), @-mention + slash typeaheads (ClideTypeahead). Quick-open intentionally left bespoke (centred persistent widget, no shared shape) — see D-88 closing amendment. Modal session/project/branch pickers stay on DialogRouter by design.', 'done', 'medium', NULL, NULL, NULL, '2026-06-08 16:30:27', '2026-06-09 06:19:36', NULL, '05f165342aa1d3708ca2d0f6e204c23e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM44CKN5QQGFKP1KWZC', 'story', '06FB0TNQM6T6580D8ABDVTMNZW', 'Edit editor settings (.editorconfig) from the settings panel', 'Write path for editor settings (T-29 follow-up). The editor now READS .editorconfig into a source-agnostic EditorSettings model (lib/src/editor/editor_settings.dart) and resolves it via editor_settings_resolver.dart. Add a settings-panel surface to EDIT these values and persist them — initially by writing the project .editorconfig (indent_style/size, end_of_line, max_line_length, trim_trailing_whitespace, insert_final_newline), later possibly a clide-owned settings file that merges as a higher-precedence source in resolveEditorSettings. On save the registry already re-resolves open buffers (editor.settings-changed), so the panel edits apply live. Respect EditorConfig semantics (sections/globs); start with the [*] section for the workspace defaults. Refs: T-29, D-88, editor_settings.dart, editor_settings_resolver.dart.', 'backlog', 'medium', NULL, NULL, 'D-88', '2026-06-09 07:15:14', '2026-06-09 07:15:14', NULL, '4ff37d3b847a5a0cbe89bab48aa27596', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM45BSF1B0R0JA1Z7FC', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'Re-resolve .editorconfig on external file changes (fs-watch)', 'T-29 follow-up. Today open buffers re-resolve their EditorSettings when a .editorconfig is saved IN-APP (a hook in EditorRegistry.save → editor.settings-changed). External edits — another editor, a git checkout/branch switch — are NOT picked up until reopen. Wire a filesystem watch so an external .editorconfig change re-resolves open buffers too. Prefer piggybacking the files subsystem''s existing watcher over a new dedicated watch; debounce; only re-resolve buffers under the changed config''s directory. Rare case, hence deferred from the initial T-29 work (the in-app save hook covers the common path). Refs: T-29, lib/src/editor/editor_settings_resolver.dart, EditorRegistry._reresolveSettings.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-09 07:15:22', '2026-06-09 07:15:22', NULL, 'ac3fcdaa95194c311e19fc22aef6fd59', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5E41Q8TX8X55X4MZ0', 'story', '06FB0TNQM5GSR5WPC2T7CJM8XW', 'editor respects .editorconfig settings', 'When a file is opened in the editor, read .editorconfig from the workspace root and apply: indent_style, indent_size, max_line_length (ruler/wrap guide), end_of_line, trim_trailing_whitespace, insert_final_newline. Parse the INI format ourselves (small, no dep). Glob matching per the EditorConfig spec.
DONE: The editor honours .editorconfig end-to-end. New source-agnostic EditorSettings model (editor_settings.dart) + composition seam (editor_settings_resolver.dart); .editorconfig demoted to a source (editorconfig.dart, own INI parser + glob matcher, root/nearest-wins precedence, no deps). Registry resolves on open and re-resolves open buffers when a .editorconfig is saved in-app (editor.settings-changed; a save hook, not an fs-watch). Editor: Tab/Shift+Tab indent + max_line_length ruler; save applies end_of_line/trim_trailing_whitespace/insert_final_newline. Follow-ups: T-290 (edit from settings panel), T-291 (external fs-watch). 100% coverage on new model/resolver/parser.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-04-22 20:17:26', '2026-06-09 10:46:41', NULL, '6d078026407dc764d9273b2fe9b94a70', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM50BPNDT50PM24ZVA4', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Slash-command typeahead breaks on ''-''; Tab-to-complete is flaky', 'Two issues in the Claude composer slash-command typeahead (T-152/T-153, lib/builtin/claude/src/slash_commands.dart + claude_composer.dart):
1. Typing a ''-'' character breaks the typeahead. Many command names contain hyphens (e.g. clear-context-style names, custom commands), but typing ''-'' appears to drop/empty the suggestion list or mis-parse the active query. Suspect activeSlashQuery''s token run or filterSlashCommands prefix matching not handling ''-'' (or the composer treating ''-'' as a boundary).
2. Tab to accept the highlighted suggestion responds flakily — sometimes it completes, sometimes nothing happens. Suspect a focus/key-handling race between the composer''s key handler and the typeahead overlay, or Tab being consumed by focus traversal before the accept intent fires.
Repro: open the composer, type ''/'' then a command fragment; (a) include a ''-'' and watch the list; (b) arrow-select an item and press Tab repeatedly.
Acceptance: ''-'' is treated as a normal command-name character (suggestions keep filtering through hyphens); Tab reliably completes the highlighted suggestion every time (insert via completeSlash). Add/extend unit tests in slash_commands_test.dart for hyphenated queries and a composer widget test for Tab-accept.
RESOLVED by the D-88 ClideTypeahead migration (T-286), verified 2026-06-09. Root cause of both symptoms was the OLD hand-rolled overlay, not the pure helpers (activeSlashQuery/filterSlashCommands always handled ''-''): (1) the old overlay didn''t narrow live as you typed, so a ''-'' looked like it emptied/broke the list; (2) the old focus model raced Tab. Post-migration the popover narrows live (ValueNotifier) and the field keeps focus (captureFocus:false), so ''-'' filters normally and Tab reliably completes. Confirmed bare ''-'' is NOT a keybinding (zoom is ctrl/meta+minus). Added regression tests: hyphen cases in slash_commands_test; composer widget test typing through a hyphen + Tab-accept. Commit d8b9a41.', 'done', 'medium', NULL, NULL, NULL, '2026-06-08 09:20:00', '2026-06-09 15:03:23', NULL, 'b398af8597d7249428c508c6365ec3dc', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4BD8PXDRHBF001200', 'bug', NULL, 'Flaky: pty session_test ''write sends keystrokes to child'' starves under parallel/coverage load', 'test/pty/session_test.dart:47 ''write sends keystrokes to child'' (and its sibling pty integration tests) intermittently fail when run in a large parallel pool — observed twice during T-29: once in a bare ''flutter test --coverage'' (whole suite, max parallelism + coverage instrumentation) and once mid pre-push gate (''+570 -1''). Passes reliably in isolation and via the gate''s SERIAL test-core pass (''dart test test/pty'', +571 ok), so it is not a logic bug.
Root cause: these tests spawn a real /bin/sh via NativePty and wait on the reader ISOLATE to deliver bytes. Under heavy CPU contention the reader isolate is starved and the 20s ioTimeout (test/helpers/timeouts.dart) elapses before the shell''s first byte / echo response is delivered. Already hardened once (T-108 swapped wall-clock sleeps for event-driven waits; first-byte Completer + 25ms-poll _waitForBuffer), but the isolate-scheduling assumption still breaks under load.
These tests are tagged ''pty'' and are meant to run in the gate''s serial pass. Fix options: (a) ensure the parallel coverage pass EXCLUDES tag:pty (run pty only in the serial pass) so they never compete for isolate scheduling — verify ci/test.sh / dart_test.yaml tag routing; (b) harden against isolate starvation (e.g. give the reader isolate priority, or assert on delivery via a more robust signal); (c) document that ''flutter test --coverage'' over the whole tree is not a supported invocation (use make test + the gate). Prefer (a). Refs: T-108, T-96 (reader-isolate hangs), T-192 (gate parallel/serial passes), D-23 (test pyramid).
FIXED 2026-06-09 (commit 0231cb4). Root cause: ci/test_core.sh ran ''dart test test/ipc test/pty ... test/pql'' in the DEFAULT parallel pool, so test/pty''s real-PTY tests contended for fds+CPU with the other core suites and the reader isolate was starved. (ci/test.sh already isolated pty via --concurrency=1 --tags pty; test_core.sh was the gap.) My manual ''flutter test --coverage'' repro was a separate wrong-invocation artifact — the flutter runner can''t reliably deliver the PTY master fd, which is why the gate excludes pty from the flutter pool entirely.) Fix: test_core.sh now runs a serial ''--concurrency=1 --tags pty'' pass + a parallel ''--exclude-tags pty'' pass, mirroring ci/test.sh. Verified: pty pass +5 stable across repeated runs; full core +571 unchanged. Option (a) from the ticket.', 'in_progress', 'medium', NULL, NULL, 'D-23', '2026-06-09 14:57:32', '2026-06-09 15:09:05', NULL, '73fd633127cb5d51b0469e4559580db1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5DM43Q9HX352STN3M', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'clide image show is not tab-aware — opens in primary tab', '`clide image show <path>` (T-249 image card) renders the image into the PRIMARY tab regardless of which tab/pane is active. It should target the active tab (or otherwise be tab-aware), not hijack the primary one.
Repro:
- Have a non-primary tab focused.
- Run `clide image show <path>`.
- Observed: image card appears in the primary tab.
- Expected: image card appears in the active/intended tab.
Notes:
- Surfaced while showing a Frame0 wireframe export during T-294.
- Likely the image-show dispatch resolves a fixed/primary slot instead of the active tab context. Audit the target-resolution path for the image-show command.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-09 15:26:00', '2026-06-09 15:26:00', NULL, 'fb011f536bf6c2734eb1fadc9b9ef5c6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM42WW0SY3XEXKE8PK8', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Chosen theme is not persisted per-repo across loads', 'Picking a theme (status-bar switcher T-234, or the Settings modal) applies live but is never saved, so every reload resets to the first bundled theme. The choice should persist PER REPO.
Root cause: ThemeController.select() (lib/kernel/src/theme/controller.dart:43) only mutates _currentName + notifies — no persistence. It is constructed in facade.dart:160 as ThemeController(bundled: bundledThemes) with no initialName, so on boot it always falls back to bundled.first.
Infra already exists — no new store needed: SettingsStore (lib/kernel/src/settings.dart) has a PROJECT scope that persists to <repo>/.clide/settings.yaml via ''project.*'' keys, and ProjectManager.openProject() already calls settings.setProjectDir() on open (project.dart:130), which loads that file.
Proposed fix:
1. PERSIST on change — wire a listener on ThemeController (in facade.dart, where both theme + settings exist) that writes the current theme to settings whenever currentName changes; use project scope (''project.theme'') when a repo is open. currentName already encodes the high-contrast variant (resolveThemeName -> ''<base>-hc''), so the HC toggle persists for free.
2. RESTORE on load — the project dir is set AFTER boot, so restoration must hook project-open (ProjectManager.openProject / settings.setProjectDir), not just construction: when a project opens, read ''project.theme'' and, if present + known, theme.select() it. Guard ArgumentError for a theme that is no longer bundled (fall back to default, don''t throw).
Open question: app-scoped fallback. Persist project-scoped per the report; optionally also write ''app.theme'' as the global default so a brand-new repo inherits the last choice instead of bundled.first. Decide during implementation.
Acceptance: pick a theme (incl. the High-contrast toggle) -> restart -> same theme restored for that repo; two repos keep independent choices; with no repo open, the app default applies; a saved theme that no longer exists degrades to the default without throwing.
Tests: SettingsStore round-trips ''project.theme''; opening a project applies the saved theme to the controller; select() persists; unknown saved theme falls back. Refs: T-234 (theme switcher), T-288/D-88 (picker), SettingsStore, ProjectManager.
FIXED 2026-06-09 (this commit). Added lib/kernel/src/theme/theme_persistence.dart + wireThemePersistence() in facade. Decision on the open question: persist BOTH project.theme (per-repo, in .clide/settings.yaml) AND app.theme (global default) — so a themed repo keeps its choice, an unthemed/new repo inherits the last global choice, and the HC variant persists (name encodes -hc). Restore prefers project over app; unknown theme is ignored (no throw). Tests: test/kernel/src/theme_persistence_test.dart (6 cases — persist app/project, HC, restore-on-open, boot restore, unknown fallback). All green.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-09 15:12:15', '2026-06-09 15:36:15', NULL, 'ea78a24a1671017d25613be8a1295ae5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM61SRVBDRFFN2S80T4', 'story', NULL, 'Render pasted images via the image viewer card in the Claude conversation panel instead of echoing only the file path', 'When the user pastes an image, the conversation panel echoes back the file path as plain text (e.g. @/home/.../paste-<ts>.png). We now have an image viewer card — render the pasted image inline using that card instead of (or in addition to) the bare path.
## Behaviour
- Detect a pasted-image path in the conversation stream and render the image viewer card.
- Still surface the file path (e.g. as a caption / subtitle on the card) so it can be copied/referenced.
- Reuse the existing image viewer card component rather than building a new one.
## Notes
- Applies to the Claude conversation panel rendering path.
- Consider failure cases: missing/deleted file, non-image paste, very large images.
CANCELLED as a duplicate of T-236 (2026-06-09). T-236 is the concrete spec for rendering pasted @path images inline in the Claude conversation; its scope now includes T-254''s path-as-caption nuance. Implement under T-236.
RETRACTED the cancellation — NOT a duplicate of T-236 (per user, 2026-06-09). Both stay open. T-254 remains its own ticket.
DONE 2026-06-09 — satisfied by D-89 together with T-236. The lightbox-expansion + path-retention (filename a11y label / unchanged copyText) intent from T-254 is delivered by the shared ImageThumbnail → lightbox; the chosen presentation is inline (not a separate card) per the user. See T-236 / D-89 / commit.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-06 09:32:54', '2026-06-09 15:55:06', NULL, '407e83a02647656a01c31757fec93197', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4GN07FN8GAXZ20YAR', 'story', '06FB0TNQM7W0NPAEG3KWNFY9DR', 'Render pasted-image @path tokens as thumbnails in the message log', 'When a user message contains a pasted-screenshot reference — an @<path> token pointing at an image (the composer''s ComposerAttachment.pathToken format, e.g. @/home/<user>/.cache/clide/pasted/paste-<ts>.png, see screenshot) — the conversation log renders it as the raw path string. Show it as an inline THUMBNAIL instead.
The composer already renders pre-send attachments as Image.file thumbnail chips (clipboard_paste.dart + claude_composer _chip); this carries that into the post-send message log (UserMessage rendering in conversation_view/conversation_card).
Scope:
- When rendering UserMessage text, detect @<path> tokens; for image paths (reuse the extension check behind _looksLikeImage: .png/.jpg/.jpeg/.gif/.webp/.bmp), render a bounded Image.file thumbnail in place of the bare token. Surrounding prose still renders normally; a message can carry multiple tokens.
- Click/activate the thumbnail to open the full-size image (a preview dialog). Keyboard-operable + a11y label (filename).
- Graceful fallback: a missing/unreadable file (pasted temp files can be cleaned up) degrades to a small placeholder or the path text via Image.file errorBuilder — never a crash/exception.
- Non-image @path tokens MAY render as a file chip (filename + icon) like the composer, but image thumbnails are the focus.
- Note: Image.file reads the path directly via dart:io, so this is NOT gated by the files.read allow-list (D-80) — the cache dir is outside the workspace and that''s fine for display.
- Display-only: the text actually sent to Claude is unchanged; this only affects rendering.
Acceptance:
1. A user message with an @<path> image token shows an inline thumbnail in the log (not the raw path).
2. Clicking/activating the thumbnail opens the full image.
3. A missing/unreadable referenced file degrades to a placeholder (or the path text), no exception.
4. Prose and any non-image @path tokens around it still render readably; multiple tokens in one message all resolve.
5. The message content delivered to Claude is unchanged (render-only). Relates to T-142 (paste attachments).
MERGED T-254 (duplicate) into this ticket. Extra nuance carried over from T-254: keep the file path available (e.g. as a small caption/subtitle under the thumbnail, or via the lightbox) so it can still be copied/referenced. Reuse the existing inline image card + lightbox (T-252) rather than a new component.
RETRACTED the earlier ''merged T-254'' note — T-236 and T-254 are distinct tickets (per user); disregard that note. T-236 stays scoped to its own description.
DONE 2026-06-09 — implemented as the hybrid (D-89): inline thumbnail in the message prose that opens the lightbox on click/Enter. New ImageThumbnail + openImageLightbox (lib/builtin/claude/src/image_thumbnail.dart); ClideMarkdown gained an onImageToken WidgetSpan seam. Missing file → placeholder; multiple tokens; non-image @path stays text; render-only (copyText unchanged). Composer chips reuse the same 44px thumbnail. Tests: image_thumbnail_test + conversation_view_test cases. Closes alongside T-254.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-03 15:31:01', '2026-06-09 15:55:06', NULL, 'bdd6cd8734a9a6a361cf6f128e3e632f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6RXN6HW6XTEHKYQDG', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Conversation doesn''t re-scroll when the bottom input resizes (content hides behind it)', 'When the bottom input area changes height, the conversation view does not re-scroll to the new viewport bottom, so the last bit of content is left hidden behind the newly-sized box.
Trigger: anything that resizes the bottom interaction zone (D-78) — e.g. opening a permission dialog or the AskUserQuestion UI, which replace/expand the composer. The taller box shrinks the conversation viewport from the bottom, but the scroll offset isn''t adjusted, so content that was at the bottom edge is now occluded.
Expected: when the interaction zone grows, the conversation re-anchors so the previously-visible bottom content stays visible above the box (preserve bottom-anchoring / keep the tail in view). Symmetric on shrink — no leftover gap when the box collapses back.
Repro:
1. Scroll the Claude conversation to the bottom (tail in view).
2. Trigger a permission prompt or AskUserQuestion (interaction zone expands).
3. Observed: a strip of the last message/card is hidden behind the enlarged input box.
4. Expected: view scrolls so that content remains fully visible above the box.
Notes:
- Likely the scroll controller doesn''t react to the composer/interaction-zone height change (no re-scroll on viewport-inset/size change). Audit the conversation view''s scroll handling around interaction-zone show/hide (D-78) and on keyboard/box resize.
- Affects the Claude conversation panel.
DONE 2026-06-09 (commit). conversation_view tracks at-bottom + a LayoutBuilder re-jumps to the tail when the viewport height changes (interaction-zone resize, D-78), only when pinned. Tests: conversation_scroll_test.', 'done', 'medium', NULL, NULL, NULL, '2026-06-09 16:01:09', '2026-06-09 16:34:46', NULL, '6503bbef73fb77041d324369d61ea208', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM72SHY93S36VM9G6D8', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Collapse buttons for sidebar and context pane', 'Design and add collapse/expand controls for the left sidebar and the right context pane, along the lines of the reference screenshot (green arrows mark the two intended affordance locations — bottom-left of the sidebar and bottom-right of the context pane).
Scope of this ticket: set up a Frame0 mock to talk through the design before implementing.
Open questions to resolve in the mock:
- Affordance placement: footer/status-bar anchored (as the screenshot arrows suggest) vs. pane-edge chevron.
- Collapsed state: fully hidden vs. thin rail with a re-expand handle.
- Iconography (chevron direction) and hover/active states.
- Whether sidebar and context pane share one control pattern (parity) or differ.
- Keyboard/CLI parity (D-6): each collapse action needs a clide verb.
Deliverable: Frame0 wireframe(s) of collapsed + expanded states for both panes, reviewed before any code.
DESIGN DIRECTION (settled): anchor both toggles on the OUTER EDGES of the center (Claude conversation) pane — one on the left edge controlling the sidebar, one on the right edge controlling the context pane. The control stays fixed on the center-pane edge whether the adjacent pane is open or collapsed, so a single button both collapses an open pane and re-opens a collapsed one (chevron flips direction). This avoids needing a separate "re-expand" handle on the collapsed pane.
Implications for the mock:
- Collapsed pane can be fully hidden (no thin rail needed) since the re-open control lives on the center edge.
- Sidebar and context pane share one mirrored control pattern (parity).
- Chevron direction reflects state: points outward to expand, inward to collapse.
IMPLEMENTATION NOTE: the collapse logic already exists — no new toggle behaviour needed. Commands `sidebar.collapse` (ctrl+shift+1) and `context.collapse` (ctrl+shift+3) are registered in lib/builtin/default_layout/src/extension.dart, exposed in the command palette + menubar, and call arrangement.toggleCollapsed(Slots.sidebar|contextPanel), returning isCollapsed (D-051, D-054).
So this ticket is scoped to the VISUAL AFFORDANCE only:
- Add the two edge-anchored toggle buttons on the center (Claude) pane''s outer edges.
- On click, invoke the existing `sidebar.collapse` / `context.collapse` commands (do NOT reimplement collapse).
- Read arrangement.isCollapsed(...) to flip the chevron direction per state.
- D-6 CLI/keyboard parity is already satisfied by the existing commands; this adds the mouse affordance.
Mock: docs/design/wireframes/hud/pane-collapse-toggles.{json,png} — State A (open) + State B (collapsed).
PLACEMENT REVISED: toggles do NOT float vertically-centered on the pane edges. They live in the BOTTOM STATUS BAR. Each toggle is horizontally pinned to the center pane''s left/right edge, so when a pane collapses the toggle slides along the status bar to that end (open: at the inner pane boundary; collapsed: at the far status-bar end — matching where the reference-screenshot arrows pointed). Still mirrored left/right for parity; chevron flips per isCollapsed. Buttons invoke the existing sidebar.collapse / context.collapse commands.
Mock updated: docs/design/wireframes/hud/pane-collapse-toggles.{json,png}.
APPROVED (2026-06-09): pinned-to-edge status-bar placement confirmed by user. Design is settled; ready for implementation.
PLACEMENT FINAL (supersedes "pinned-to-edge"): toggles are FIXED at the far-left and far-right ends of the bottom status bar in every state. They do not slide with the pane edge. Position is constant (muscle memory; always where the reference-screenshot arrows pointed); only the chevron flips per isCollapsed. Left end = sidebar toggle, right end = context toggle. Mirrored for parity. Buttons invoke the existing sidebar.collapse / context.collapse commands.
Mock updated: docs/design/wireframes/hud/pane-collapse-toggles.{json,png}.
SPACE NOTE: the status bar already hosts content at both ends (left: branch / skills count; right: Output / terminal). Fixed-end toggles therefore share that space. Resolution: reserve the OUTERMOST ~24px cell at each end for the toggle and shift the existing status items inward by that width — a constant reservation, not a dynamic fight (the toggle never moves or grows). The mock reflects this: status text begins after the left toggle and ends before the right toggle.
GLYPH DECISION: use Phosphor caret-line icons (chevron + edge line), which read as "collapse to the edge":
- caret-line-left -> 0xe132 (CaretLineLeft)
- caret-line-right -> 0xe130 (CaretLineRight)
The glyphs already ship in assets/fonts/phosphor (codepoints.csv lines 150-151) — no new dependency. Add two consts to lib/widgets/src/icons/phosphor.dart (PhosphorIcons.caretLineLeft / caretLineRight) during implementation. Chevron-line direction flips per isCollapsed.
RESOLUTION (2026-06-09): considered moving the re-open control into the center (pane edge or center status segment) to dodge the end-of-bar space contention; user chose to KEEP TOGGLES FIXED AT THE STATUS-BAR ENDS (best muscle memory). Final design = v3 mock: caret-line glyphs at fixed far-left/far-right status-bar cells (~24px reserved, side items shifted in), chevron-line flips per isCollapsed, firing existing sidebar.collapse / context.collapse. Design fully settled; ready to implement.
DONE 2026-06-09 (commit). Fixed ~24px caret-line toggles bookend the status bar (StatusbarHost), flip chevron per arrangement.isCollapsed, fire existing sidebar.collapse/context.collapse. Visual affordance only. Tests: app_collapse_toggle_test + updated app_statusbar_test.', 'done', 'medium', NULL, NULL, NULL, '2026-06-09 15:18:22', '2026-06-09 16:34:46', NULL, 'c138ffeffc36fcd39fbef562519377e9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM63TRGBFRWK9W2WA98', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Group consecutive edits to the same file into one collapsed card', 'When Claude makes multiple subsequent edits to the SAME file, bundle them into a single collapsed holder card instead of rendering each edit as its own card — mirroring how we already fold meta/activity runs.
Behaviour:
- Detect a run of consecutive edits targeting the same file_path and group them into one ClideHolderCard (the shared container from T-266; see _ActivityCard in lib/builtin/claude/src/conversation_view.dart:681).
- Collapsed (default): one-line ticker of the latest edit + a count label.
- Show the count as "# edits" (e.g. "3 edits" / "1 edit"), NOT "# steps". The existing activity card uses stepLabel = ''$count steps'' at conversation_view.dart:683; this run wants an edits-flavoured label.
- Expanded: every individual edit, each with its full report — bundle, do NOT drop or summarise away any information. All per-edit detail must remain reachable on expand.
Notes:
- Reuse the existing ClideHolderCard / folding machinery rather than building a new card.
- Grouping breaks when the file_path changes or a non-edit step interleaves (consecutive-same-file only), matching the ''subsequent edits to the same file'' wording.
- Parity with the existing meta/activity folding (T-230) — same collapse/expand affordance, just an edits-labelled run.
WORKED EXAMPLE (from user screenshot, clide_markdown.dart run): the current stream renders 11 stacked cards — 3x "Edit clide_markdown.dart", then a folded "Read … 2 steps" holder, then 7x "Edit clide_markdown.dart". With this feature it collapses to THREE cards:
- [3 edits] (the first edit run)
- [2 steps] (the existing Read holder — unchanged; this is what splits the edit run)
- [7 edits] (the second edit run)
Confirms the split rule: an interleaving non-edit step (here the folded Read run) breaks the consecutive-same-file edit grouping into two separate edit cards. Same-file edits with nothing between them collapse into one "# edits" card.
STATUS INDICATOR (live tick reuse): the bundled edits card carries ONE header status indicator that reuses the existing per-step success tick, driven by the latest edit''s state:
- edit in flight -> spinner
- edit completed -> success check (the current green tick)
- next edit starts -> back to spinner
- ...repeat, settling on check when the final edit in the run completes (or the error glyph if one fails).
So the collapsed card''s indicator animates spinner<->check as the run grows, rather than showing a static tick. Each individual edit keeps its own tick in the expanded list (unchanged); this is the aggregate indicator on the holder header/ticker. Mirror the same treatment for the existing "# steps" activity card if it doesn''t already do this.
DONE 2026-06-09 (commit). coalesceEditRuns folds consecutive same-file edits into one ''# edits'' ClideHolderCard; aggregate live status via new ClideStatusIndicator (running/success/error) + ClideSpinner (logo-mark, 3D Y rotation, reduced-motion aware), shared with the activity card. Per user: spinner is a self-contained component (not built on ConversationCard''s mark) with an AnimatedSwitcher seam for a richer spinner→check transition later. Tests: activity_cluster_test (coalesce), conversation_view_test (edits card), clide_status_indicator_test.', 'done', 'medium', NULL, NULL, NULL, '2026-06-09 15:43:58', '2026-06-09 16:34:46', NULL, 'ed946676507cc9223eaefad054fb9def', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7K1FN7KD8S5ZVX2VM', 'task', NULL, 'Automate supply-chain checks in CI (osv-scanner + native SHA verification)', 'Salvaged from gemini-report.md (external code-analysis). Today supply-chain checks are MANUAL — Makefile ''security'' target just prints ''Dart advisories reviewed manually before pubspec.yaml bumps''; there is no automated vuln scan or native-artifact integrity check. Automate both to harden the chain as we move to automated CI, complementing the existing prefer-zero-deps / exact-pin / licenses.yaml discipline (D-31, D-61, D-63).
1. Automate Dart dependency vulnerability scanning:
- Run an automated scanner (e.g. Google osv-scanner) against pubspec.lock on every push/PR.
- Wire into the CI workflow and/or the Makefile ''security'' target so it runs in push-check.
- Fail the gate on known advisories; keep it quiet/zero-noise otherwise.
2. Automate native/vendored dependency SHA256 verification (D-63):
- CI step / pre-push script that parses assets/licenses.yaml (+ relevant BUILD.md) for declared native artifacts (dugite-native, libtree-sitter.so), and verifies the SHA256 of the vendored binaries against the committed/expected hashes.
- Guards against silent corruption or tampering of vendored binaries; ensures they match the audited sources.
Notes:
- Both should be low-noise, runnable locally and in CI (candidate home: ci/security.sh + a Makefile target, surfaced via push-check).
- Scope is automation only — the manual review discipline already exists; this makes it enforced rather than convention.
- Source report (gemini-report.md) is being removed from the repo once this ticket captures its only actionable content.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-09 16:42:47', '2026-06-09 16:42:47', NULL, '5eed19fc0a5b2671003c00dedbb2f3ea', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM53HQD6YV754GKBA60', 'story', NULL, 'image paste in Claude panel prompt bar', 'Support pasting images (Ctrl+V / right-click paste) into the Claude panel text input bar. The image should be sent as part of the message context to Claude, matching Claude Code CLI multimodal input.', 'done', 'medium', NULL, NULL, NULL, '2026-04-23 20:25:26', '2026-06-09 16:56:55', NULL, '3655929898ec949a983a88ba748d9f01', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM62FKQQD0B9B80PFY4', 'task', NULL, 'Team sidebar: account/team token budget (blocked on upstream)', 'Show the account/subscription usage budget (5-hour + weekly limits, % used, reset times) in the Claude meta sidebar (T-141). BLOCKED: this data is not programmatically exposed under subscription (OAuth) auth as of claude 2.1.150 — verified empirically + via docs (2026-05-23). /usage is TUI-only (headless ''claude -p /usage'' returns only a one-liner); stats-cache.json has activity counts only; the stream-json rate_limit_event is undocumented + needs a billed turn; ''claude auth status --json'' shows plan only. A ''claude usage --json'' + a /v1/organizations/{org}/usage/subscription endpoint are an OPEN, unshipped feature request (GitHub anthropics/claude-code#44328). Revisit when #44328 ships or an API-key usage path exists. See project memory ''claude-usage-budget-not-exposed''.
2026-06-09: detached from T-132 (which is otherwise complete) and made the RESOLVER ticket for Q-34 (how + when to surface the budget given upstream doesn''t expose it). Stays in the backlog; revisit when a viable data path lands (upstream claude usage --json / endpoint per anthropics/claude-code#44328, or an API-key usage path).', 'backlog', 'low', NULL, NULL, NULL, '2026-05-23 20:48:38', '2026-06-09 17:16:34', NULL, '1a58f3b9021325310bc448e5a755f4f6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7D5DMYF5X92Q55CBW', 'task', '06FB0TNQM7W0NPAEG3KWNFY9DR', 'Persist + expose the activity-card fold level (T-230 follow-up)', 'Follow-up to T-230. The activity-card fold level is implemented as a switchable ConversationView.foldLevel parameter (FoldLevel none/tools/thinking/everything; groupConversation re-groups per level, unit-tested), but the live Claude pane currently uses the L1 default with no way for the user to change it. Finish acceptance #4: (1) read the fold level from a persisted user setting (via ctx.settings / the kernel settings service — see how other builtins read settings), defaulting to L1; (2) add a control to change it (e.g. a command ''claude.activity.fold-level'' cycling none->L1->L2->L3, and/or an entry in the Claude/settings UI); (3) thread it into the ConversationView built in claude_pane.dart (and team_panel_host.dart). The grouping + card + a11y are already done in activity_cluster.dart + conversation_view.dart.
DONE 2026-06-09. Persisted app-scoped setting kActivityFoldLevelKey (app.claude.activityFoldLevel); command claude.activity.fold-level cycles none→tools→thinking→everything (foldLevelFromName/nextFoldLevel in activity_cluster.dart); ClaudePane + team_panel_host read the setting and re-fold live via the settings notifier. Tests: foldLevelFromName/nextFoldLevel in activity_cluster_test.', 'done', 'low', NULL, NULL, NULL, '2026-06-03 15:17:29', '2026-06-09 17:16:34', NULL, 'eccaa5f62d3b6f50eb984a4b3d7bc169', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7W0NPAEG3KWNFY9DR', 'epic', NULL, 'Native Claude integration: transcript-rendered conversation + tmux agent-team panels', 'Replace the terminal-based Claude pane with native rendering driven by Claude Code''s transcript JSONL (with cross-widget text selection), and surface experimental tmux agent teams (lead + teammates) as tiled native panels plus a team-meta sidebar. The terminal is retained only as a general IDE tool.
Why:
1. No cross-widget text selection/copy in clide''s native widgets today (regression vs a terminal).
2. PTY/TUI rendering is the fragile, OS-variant part of the Claude pane.
3. Claude Code''s experimental tmux team mode (lead + N teammates, each a tmux pane) is the flagship capability to surface as real GUI panels, not scraped TUI.
Direction: run Claude headless in tmux; render its conversation natively from the transcript JSONL; lead panel (left, with composer) + teammate tiles (right) in a responsive auto-wrap grid (1->2->3 cols); team-meta sidebar with roster + token budget.
Principle: Claude-centric first, CLI-first (D-6) a strong second. Accept isolated, version-pinned coupling to Claude Code internal contracts where it serves the Claude integration; preserve CLI/event surfaces where sensible.
Acceptance: all child tickets done; D-record landed; Claude pane renders natively with working select+copy and no terminal; a tmux team surfaces as lead + auto-wrapping teammate tiles + meta sidebar; all fragile CC-internals parsing isolated and version-pinned.
Source: plan iridescent-tinkering-umbrella (2026-05-22), grounded in an-idea.md + read-only validation of the transcript schema this session.
STATUS CORRECTED (2026-06-09): not superseded — substantially DELIVERED. 32/34 children done; the native Claude pane (transcript rendering, select/copy, no terminal), tmux team tiles + meta sidebar, composer, and D-75 all shipped. Re-flagged from ''ready'' (misleading — no startable work) to in_progress. Remaining, both non-blocking, kept under this epic: T-158 (team/account token budget — blocked on upstream /usage exposure) and T-235 (persist activity-card fold level, T-230 follow-up). Close when those land.
CLOSED 2026-06-09: all doable work delivered (native Claude pane, transcript rendering, select/copy, no terminal, tmux team tiles + meta sidebar, composer, activity-card folding incl. the persisted fold level T-235, D-75). The one blocked item — account/team token budget — was detached (T-158) and reframed as Q-34 (how + when to surface it given upstream doesn''t expose the data); T-158 is the resolver, parked in the backlog. Nothing actionable remains under this epic.', 'done', 'high', NULL, NULL, NULL, '2026-05-22 15:45:15', '2026-06-09 17:23:25', NULL, 'd4abcdfdd0f3d79570f331c7385e36e5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7F2TBJJV1F2KP7XR8', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Clickable file references in Claude conversation open in editor', 'Make file-path references rendered in the Claude conversation pane clickable: clicking a path opens that file in the editor.
**Behavior**
- Detect references to files in the workspace as the conversation markdown is rendered — bare paths (`lib/app.dart`), `file:line` forms (`lib/app.dart:42`, clickable per CLAUDE.md), and likely inline-code spans / markdown links pointing at repo paths.
- Resolve against the workspace root (CLIDE_WORKSPACE / git repo root). Only linkify paths that exist in the repo to avoid false positives on prose.
- On click, open in the editor — same path as `clide editor open <path>` — and jump to the line when a `:line` suffix is present.
**Notes / constraints**
- Markdown rendering is clide-owned custom CustomPaint/widgets (not a package), so detection + hit-testing lands in the conversation/markdown render path.
- Honors User/Claude parity (D-6): the click maps to the existing `clide editor open` verb.
- Open questions to settle during design: how aggressive path detection should be (existence check vs. heuristic), handling of non-existent / external paths, and visual affordance (underline/hover) for a linkified ref.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-09 20:02:41', '2026-06-09 20:03:08', NULL, '1996bc502ccd18ba28203e362ce82bc7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6048VEGFDNEBHFMM4', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Display in-repo file paths relative to repo root in Claude conversation', 'When file paths to files inside the current repo appear in the Claude conversation, show them relative to the repo root rather than as absolute paths.
**DESIGN OPEN — needs discussion before implementation.** The exact presentation is undecided.
**Intent**
- Paths that resolve inside the workspace (CLIDE_WORKSPACE / git repo root) should read as repo-relative (e.g. `lib/app.dart` instead of `/var/mnt/data/projects/clide/lib/app.dart`).
- Goal is readability — strip the absolute prefix that''s noise for in-repo files.
**Open questions to settle in discussion**
- Visual treatment: silently rewrite the displayed text? show relative with the absolute available on hover/tooltip? a leading marker (e.g. `./` or a repo-root glyph)?
- Scope: only linkified/recognized refs (ties to T-300), or any path-looking token in the rendered output?
- Out-of-repo / absolute paths: leave untouched, or abbreviate (e.g. `~`)?
- Interaction with copy: does copying yield the relative or the original absolute path?
- Does this happen at render time only, or is the underlying text also normalized?
**Related**
- Pairs with T-300 (clickable file references) — same conversation/markdown render path; likely share path-detection + workspace-root resolution.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-09 20:04:29', '2026-06-09 20:04:54', NULL, 'a5da33062dd9373142d5292a79de8d64', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4AE2GEF6BXSGKRMGC', 'task', NULL, 'Quiet test reporter: use failures-only in ci scripts (only failures + final count)', 'VERIFIED DONE 2026-06-09: failures-only is the default reporter across all gate scripts — ci/test.sh (REPORTER var, used in every flutter/dart test invocation), ci/test_core.sh, ci/test_a11y.sh — each with a TEST_REPORTER=expanded escape hatch for debugging. No work remaining.', 'done', 'medium', NULL, NULL, NULL, '2026-06-05 10:17:47', '2026-06-09 20:07:30', NULL, '05823664d6d7b3cefbbc3f6c86c199b0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM42VPM50VKAWXBCAM8', 'story', NULL, 'Make links in the Claude conversation panel clickable to open in OS URL handler', 'The Claude conversation panel detects URLs and colorizes them, but they are not interactive. Make detected links clickable (a plain click, or control/cmd-click) so they are handed off to the OS URL opener.
## Behaviour
- Click (or ctrl/cmd-click) on a colorized link opens it via the OS default handler.
- Hover affordance (cursor change / underline) so it reads as clickable.
- Keep the existing colorization.
## Notes
- Honour user/Claude parity (D-6) where relevant.
- Use the platform URL launcher; avoid pulling in an opinionated package if a thin native/url_launcher shim already exists in the tree.
- Guard against non-http schemes / malformed URLs.
DONE 2026-06-09. http(s) links in the conversation open via OsBridge.openURL on click (hover underline + pointer); non-http inert. ClideMarkdown gained an onLinkTap hook + _urlLinkSpan; the 3 inline-interaction callbacks were bundled into ClideMarkdownHooks (single threaded param) — also fixed links/images only working in some markdown contexts. Tests: clide_markdown_test.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-06 09:32:53', '2026-06-09 20:17:04', NULL, '4b6b10e6e323af31ce0e87055cf738d5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7H8JWCP59WQVD0FW4', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'Frame0 wireframe: settings screen design', 'Design the app''s settings screen as a Frame0 wireframe before building `builtin.settings-ui` (see Tier 6 epic T-8). Output is a wireframe to align on layout/IA, not implementation.
**Deliverable**
- Frame0 wireframe authored via the frame0-wireframe skill: local JSON source-of-truth synced to Frame0, exported for review.
- Covers the settings screen shell + at least one fully-rendered category so the form-field patterns are concrete.
**Scope to frame (from T-8)**
- Schema-driven panel: form fields keyed off the schema each subsystem registers against the kernel SettingsStore; edits write back to `.clide/settings.yaml`.
- Navigation/IA: how categories are grouped and selected (sidebar list? sections? search?).
- Field types to mock: toggle, enum/select (e.g. keymap preset), text/number, and a ''opens external file'' affordance (e.g. editor `.editorconfig` per T-290).
- Known consumers to account for: keymap preset switching (T-115/T-64/T-65/T-66), editor settings (T-290), activity fold level (T-183), theme picker (Tier 6 theming UI).
**Open design questions for the wireframe to answer**
- Settings as a full-screen view, a pane/tab, or a modal?
- Per-project (`.clide/settings.yaml`) vs. user-global scope — shown together or switched?
- Search/filter across all settings.
- How schema-driven fields render labels, help text, defaults, and reset.
**Constraints**
- Follow clide visual language — pull theme tokens / control geometry from the ui-design skill so the wireframe maps cleanly to real widgets (no Material/Cupertino).
This is the design step; implementation of the actual settings UI is separate child work under T-8.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-09 20:24:54', '2026-06-09 20:25:49', NULL, 'cebb365e52ecabe1de21f958aae69a9a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5QJC039TMDF5JGMXC', 'task', NULL, 'Number-key shortcuts for interactive prompt buttons (CLI-style 1/2/3)', 'Muscle-memory gap from the Claude Code CLI: in clide''s interaction-zone prompts (D-78), the user reaches for number keys 1/2/3 to pick a button but has to click. Map number keys (1..N) to the visible buttons/options, and PREFIX each button label with its number for discoverability (CLI shows ''1. Allow / 2. ... / 3. No'').\n\nSurface: ToolPromptCard in lib/builtin/claude/src/prompt_card.dart. Two modes:\n- PERMISSION (_permission, ~line 119): buttons Allow (primary) / ''Allow & don''t ask again'' (only when permissionSuggestions present) / Deny. Map 1=Allow, 2=''Allow & don''t ask again'' (when shown) else Deny, 3=Deny when the middle one is shown. Match the CLI''s ordering/numbering.\n- ASKUSERQUESTION (_question / _optButton, ~line 280/294): each option per question gets a number (1..N) that selects (single) or toggles (multiSelect) it; keep Enter for Submit/Next and Esc/back as-is. Prefix option labels with the number alongside the existing radio/checkbox glyph (the ''○/●'' in _optButton).\n\nKEY CAVEAT (do this right): the prompt card hosts a free-text note field (_NoteField) and AskUserQuestion ''Other'' free-text. Number keys MUST NOT be captured while focus is in a text field (otherwise typing ''1'' triggers a button). Gate the shortcut on focus not being in an editable, i.e. intercept at the prompt''s Focus/FocusScope and bail when a text field has focus — same consumer-interception discipline as D-82 (Vim) / the editor. Keep the ClideButtons (clicks + AT) intact; the number key is an additional accelerator. Accessibility: include the number in the button''s semantics label.\n\nAcceptance: with a permission prompt or AskUserQuestion open and focus not in a text field, pressing 1/2/3 (and up to N for question options) activates the matching button/option; labels are number-prefixed; typing in a note/Other field is unaffected; mouse + screen-reader paths still work.
Refinement (2026-06-05, user): match the Claude Code CLI''s actual behavior for the text-field interaction. The number key triggers the button/option shortcut when it would be the FIRST character typed -- i.e. focus is NOT in a text field, OR focus IS in the note/Other field but that field is currently EMPTY. Once the field has any content, digits type normally (no shortcut). This supersedes the earlier ''never capture digits while a text field has focus'' gate: it''s better because focus often defaults into the (empty) note field, so the shortcut still fires there (muscle-memory case) while a digit mid-note still types. Implementation: intercept the digit at the prompt focus scope; consume+activate only when the focused editable (if any) is empty, else let it through to type. The CLI exhibits this exact ''footgun'' (digit-as-first-char in an empty field acts as the choice) and we intentionally mirror it.
DONE 2026-06-09. ToolPromptCard: number keys pick the matching button/option (labels prefixed 1./2./3.), Enter confirms the primary action. Permission 1=Allow / 2=Allow&remember(if offered) else Deny / 3=Deny; AskUserQuestion 1..N select+toggle options + Other. Card autofocuses; _onKey self-guards on hasPrimaryFocus so a focused note field types digits normally. Permission/option actions shared between buttons + keys. Tests: prompt_card_test number-key group + updated label finders.', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-05 09:38:46', '2026-06-09 20:27:17', NULL, '18039168ae44b4bd32d516ada0da1a46', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM531VE0C6N36AT12SR', 'task', NULL, 'Audit widget tests for ultrawide coverage (IDE runs wide)', 'Lesson from T-239: a width-PROPORTIONAL layout bug (status-bar right group drifting to mid-bar via a Spacer-vs-flex split) was invisible at the default 800px flutter_test surface and at 1200px, but glaring at 3440px. clide is an IDE — ultrawide (3440, 5120, ultrawide+vertical splits) is a VERY common real screen size, so width-sensitive layout that only ever gets tested at 800px hides exactly this class of bug.\n\nScope: review the widget/golden suite and add ultrawide coverage where layout is width-sensitive. Candidates: the bottom status bar (done — test/app_statusbar_test.dart now covers 600 + 3440), panel/slot layout + drag-resize (lib/app.dart RootLayout, SlotHost), tab strips (overflow/scroll at wide), ClideMarquee (T-160 — only tested narrow; also check it doesn''t mis-behave wide), conversation view / activity card, the command palette + quick-open overlays (max-width/centering on wide), modal pickers, status items.\n\nApproach: (1) add a shared test helper to pump at a given surface width via tester.view.physicalSize (note: a wide SizedBox under the default 800px surface is CLAMPED — must set view.physicalSize, see test/app_statusbar_test.dart pumpAt). (2) For layout-sensitive widgets, assert key positions/no-overflow at BOTH a normal and an ultrawide width. (3) Don''t blanket-add to every test — target width-sensitive layout (Row/Spacer/Expanded/Flexible/Align, max-width caps, centering). Note any widget that SHOULD cap/center on ultrawide (readability) vs fill. Relates to Q-26 (small-screen layout) — same responsive concern at the other end.
DONE 2026-06-09. Added the reusable setSurfaceSize(tester, width) helper to test/helpers/widget_harness.dart (the foundation the ticket asked for). Ultrawide (3440) cases added on the flagged width-sensitive surfaces: ClideMarquee (was narrow-only — now verified static when a line fits a wide slot) and the quick-open palette (verified width-capped, not stretched). Status bar already covers 600+3440 (T-239). AUDIT NOTES on the rest: overlays/pickers use fixed-width panels (ClideMenu/quick-open 480, anchored popovers) so they''re inherently capped/centered; tab strips (MultitabPane) and status items overflow only when NARROW (a small-screen concern, Q-26), not wide; RootLayout slots are explicit fixed/flex widths with drag-resize. The helper is now in place for any future width-sensitive widget. Not exhaustive by design (audit + reusable tooling + the high-risk surfaces).', 'ready', 'medium', NULL, NULL, 'D-23', '2026-06-05 10:02:31', '2026-06-09 20:34:30', NULL, 'cad1d9909d676a7395a46320e9702560', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4K1MTP2MHRKKNXBFM', 'task', NULL, 'macOS: deliver clide:// deep links via AppDelegate openURLs', 'Follow-up to T-56. The clide:// scheme is registered in macos/Runner/Info.plist (CFBundleURLTypes) and the parser+routing are done (parseArgv → editor.open → IPC). But macOS does NOT pass the URL as argv like Linux does (Exec %U); it delivers via the AppDelegate. Wire AppDelegate.application(_:open:) (or applicationDidFinishLaunching for a cold-start URL) to forward the clide:// URL string to Dart over a method channel (mirror the existing clide/window + clide/clipboard channels), and on the Dart side run it through parseArgv and dispatch to the local IPC (same path the CLI uses). Needs a real macOS machine to verify — deliberately not shipped blind in T-56. Refs: macos/Runner/AppDelegate.swift, lib/src/cli/argv_to_request.dart (_deepLinkToRequest).', 'backlog', 'low', NULL, NULL, NULL, '2026-06-09 20:41:22', '2026-06-09 20:41:22', NULL, 'c1943138c8e1f3dedd400bc525400bf2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4BRWWPB4R06Q31XV0', 'story', NULL, 'deep link protocol handler', 'Register clide:// protocol handler. Support clide://open?path=/repo/file.md&line=42 for external tools to open files at specific locations. Useful for CI links, error reports, and cross-tool integration.
DONE (Linux) 2026-06-09. clide://open?path=&line= → editor.open via parseArgv, routed through the existing CLI→IPC path into the running window (single-instance for free). Parser validates action/path/line; tests in argv_to_request_test. Scheme registered: linux/clide.desktop (x-scheme-handler/clide, Exec already %U) + macOS Info.plist CFBundleURLTypes. Linux works end-to-end. macOS URL DELIVERY (AppDelegate openURLs → method channel → Dart) deferred to T-303 — needs a macOS machine to verify, not shipped blind.
SECURITY REDESIGN 2026-06-09 (D-90): per user, the clide:// surface is now paranoid. The link no longer translates to a command in parseArgv — it routes the raw URL to a new builtin.deeplink handler gated by a DEFAULT-DENY allowlist (kDeepLinkSafeActions = {open} only — run/git/write/passthrough rejected) AND a mandatory confirmation prompt (''an external link wants to: … allow?'') before any action. The generic CLI-passthrough idea is deliberately NOT shipped (it would make a webpage a remote control); each new action is an explicit allowlist + handler addition. Tests: deep_link_test (allowlist boundary), extension_test (gating).', 'done', 'medium', NULL, NULL, NULL, '2026-04-23 20:32:06', '2026-06-09 20:57:53', NULL, '26038fc931f2848f7327905c101de662', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6SK3D1QYACBPHHBZ0', 'bug', NULL, 'GLib-GIO-CRITICAL: GFileInfo created without standard::size on startup (Linux)', 'On Linux desktop startup, two GLib-GIO-CRITICAL warnings fire to stderr:
GLib-GIO-CRITICAL: GFileInfo created without standard::size
file ../gio/gfileinfo.c: line 1865 (g_file_info_get_size): should not be reached
Repro: launch the app (make run / flutter run) on Linux. The pair fires early in boot — right after the primary pane binds its session and before the IPC server starts listening (observed ~0.3s apart, e.g. 15:53:15.570 and 15:53:15.832), on every launch.
Cause: some code path calls g_file_info_get_size() on a GFileInfo that was created/queried WITHOUT requesting the G_FILE_ATTRIBUTE_STANDARD_SIZE (''standard::size'') attribute. A grep of clide''s own code (lib/, linux/runner/, native/) finds no direct g_file_info / g_file_query_info usage, so it is most likely inside GTK/GLib itself or a Flutter Linux plugin''s file enumeration (icon/thumbnail/mime probe, path lookups), not clide Dart/C++. The GTK file-chooser in linux/runner/clide_app.cc is on-demand only, so it is not the trigger (the warnings fire at boot).
Impact: low — console noise at CRITICAL level; no observed functional breakage. But a size query that ''should not be reached'' may be reading a bogus/zero size somewhere worth confirming.
Investigation: run with G_DEBUG=fatal-warnings (or gdb break on g_log/g_logv) to capture the stack at the warning and identify the library/plugin frame; check whether a Flutter plugin (file_selector, path_provider, url_launcher) or GTK icon/mime loading is responsible. If upstream/GTK, document + suppress-from-our-side or pin; if a plugin, file upstream.
Env: Fedora, GTK Linux embedder, flutter run.
UPDATE (2026-06-09): also fires MID-SESSION, not only at boot — contradicts the "fires early in boot, on every launch" framing above. Observed log: app booted 16:47:01, but the GLib-GIO-CRITICAL pair fired at 18:51:07.841 / 18:51:08.190 (~2h into the session), near pane/session activity. So the trigger is more likely a file-info code path tied to a user action or background file enumeration than pure startup. Re-scope the investigation to capture the stack when it fires mid-session (G_DEBUG=fatal-warnings / gdb break on g_log) rather than only at boot.
RESOLVED (2026-06-09).
Root cause CONFIRMED by reproducer: the Open Workspace folder picker. `gtk_file_chooser_dialog_new(..., SELECT_FOLDER, ...)` at linux/runner/clide_app.cc:222 builds a GtkPlacesSidebar that enumerates bookmarks/recent and internally creates GFileInfo objects WITHOUT G_FILE_ATTRIBUTE_STANDARD_SIZE, then calls g_file_info_get_size() on them — GTK''s own bug. A standalone repro (gtk3, SELECT_FOLDER dialog, auto-cancel) emitted the exact warning pair ~0.3s apart, matching the user''s mid-session log (18:51:07.841 / 18:51:08.190). The original "fires at boot" framing was wrong; the picker is opened on demand. Ruled OUT the T-138 clipboard channel: gtk_clipboard_wait_for_uris/wait_for_image emit nothing for text/image/file-uri clipboard states under G_DEBUG=fatal-warnings.
No public GTK API exists to fix at source (the sidebar''s enumeration is private; can''t inject the missing attribute).
Fix (both, per user direction):
1. Switched pickDirectory to GtkFileChooserNative — portal-backed, runs out-of-process in sandboxed/Flatpak builds so the noise never enters our process; nicer Wayland picker. (Verified: outside a sandbox GtkFileChooserNative falls back to the in-process chooser and STILL warns on this box — portal is only used under Flatpak/snap or GTK_USE_PORTAL=1.)
2. Added clide_gio_log_filter in clide_app.cc, installed via g_log_set_handler("GLib-GIO", CRITICAL, ...) in clide_app_startup. Drops ONLY messages containing "g_file_info_get_size" or "without standard::size"; forwards every other GLib-GIO log to the default handler. This is the universal fix (works in every run mode).
Verification: standalone reproducer with both the native chooser AND the filter — size warnings gone, an unrelated GLib-GIO CRITICAL still passes through (filter is scoped, not a blanket mute). `make build-linux` green. CHANGELOG updated under Fixed.', 'done', 'low', NULL, NULL, NULL, '2026-06-08 17:12:49', '2026-06-09 21:21:08', NULL, 'bb5703a93c41803651ecd4c1210b23bb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4KS233FGZE9H7ABWR', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Activity-card spinner too small to make out — enlarge it', 'The logo-mark spinner shown on in-progress activity/holder cards in the Claude conversation is too small to read as a spinner — it reads as a static speck. Enlarge it so the running state is legible at a glance.
**Where**
- `ClideSpinner` (lib/widgets/src/clide_spinner.dart) — defaults to size 14; renders the logo SVG at width/height = size.
- `ClideStatusIndicator` (lib/widgets/src/clide_status_indicator.dart) — default size 14; maps running→ClideSpinner, success→check, error→cross at the same size.
- Call sites: holder_card.dart:117 and :199 pass `size: 12` — the small value the user is seeing.
**Direction (settle in review)**
- Bump the spinner size on the activity cards (the `size: 12` call sites, and/or the indicator default) to something clearly legible — pull a concrete value from the ui-design control-geometry tokens rather than a magic number.
- Keep the running spinner, success check, and error cross visually balanced at the new size (they share `size`), so the card doesn''t jump when the state settles.
- Check the other ClideSpinner/StatusIndicator consumers (status surfaces) so the bump doesn''t bloat unrelated spots — may warrant sizing the cards explicitly rather than changing the shared default.
**Acceptance**
- The in-progress spinner on conversation activity cards is comfortably distinguishable as a spinning indicator; success/error glyphs stay aligned at the same footprint.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-09 21:22:28', '2026-06-09 21:22:44', NULL, 'c29f22db34a367ee5eb7234a75e75616', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM50S6WRD81V3Z0NH1M', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Bottom input box is misaligned when the status bar is hidden', 'The bottom input/prompt box (the composer, shown with the edit-pencil affordance on the right) is aligned weird when the status bar is not visible.
Symptom: with the status bar hidden, the input box''s alignment/padding looks off (see screenshot — single-line box spanning full width, content not sitting where it should relative to the window bottom).
Likely cause: the composer''s vertical alignment / bottom padding assumes the status bar is present below it and reserves/relies on that space. When the status bar is hidden, that assumption breaks and the box no longer sits flush/centered correctly.
Expected: the input box sits correctly flush to the window bottom (or with consistent padding) regardless of whether the status bar is visible. Layout should not depend on the status bar being present.
Notes:
- Audit the bottom-of-window layout stack (composer / interaction zone + status bar). The composer should bottom-anchor independently of the status bar''s visibility.
- Related to the bottom interaction zone work (D-78) and may interact with T-297 (scroll on input resize). Also relevant once the status-bar collapse toggles (T-294) can change what''s in the bar.', 'done', 'medium', NULL, NULL, NULL, '2026-06-09 16:06:41', '2026-06-09 21:36:05', NULL, 'a82415278c5d2653d6e7092a0fbbb6e8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5YNEQM1WY25BV7TD8', 'bug', NULL, 'app_test ''Open Folder non-repo'' hangs 10min — runAsync/ReceivePort teardown wedge', 'Pre-existing (reproduces at base commit 3a78dfa, predates the T-267 conversation-rendering epic). The widget test `test/app_test.dart` "Open Folder on a non-repo path surfaces the ''no git repo'' dialog" times out after 10 minutes; teardown is wedged on `_RawReceivePort._handleMessage`.
Bisected (each a 45s-timeout repro, all on this box):
- A bare `Process.run` inside `tester.runAsync` (no app, no extensions) hangs → `Process.run`-in-`runAsync` leaks its exit ReceivePort here.
- `pumpApp` + empty `runAsync`, and `pumpApp` + a 1.5s real delay → both PASS (boot + runAsync alone is fine).
- The full openFolder tap flow hangs even when project validation is stubbed to a synchronous, pure-Dart `.git` walk (no subprocess) AND `runAsync` is removed — so the wedge is not solely the git subprocess; something in the booted-app + extensions + open-folder command path holds a native port that teardown waits on forever.
Quarantined with `skip:` so the suite/gate stays green. Real fix: find the leaked native async resource (likely a Process/Isolate/FakeDaemonClient port reachable from the open-folder command or app boot under the test harness) and ensure it''s drained/cancelled before teardown — or drive the "no git repo" assertion without booting the resource. Then remove the skip.', 'done', 'high', NULL, NULL, NULL, '2026-06-08 11:29:22', '2026-06-09 21:42:59', NULL, 'c49e75c2d73ddec10469bf2405dde626', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB16FJ7KXGFHQXEG88MYRFTG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Unify collapser cards onto one ClideCollapserCard primitive', 'Group/tool collapser cards (ClideHolderCard vs ConversationCard) handle collapse + visuals inconsistently; extract one shared collapser primitive with a color property, fixed-width counter slot, and the status icon hard against the card edge.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 08:15:39', '2026-06-10 08:15:39', NULL, '0c5af482db33b4ea4dd241bd3491a51f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB16FJ7KXGFHQXEG88MYRFTG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Unify collapser cards onto one ClideCollapserCard primitive', 'Group/tool collapser cards (ClideHolderCard vs ConversationCard) handle collapse + visuals inconsistently; extract one shared collapser primitive with a color property, fixed-width counter slot, and the status icon hard against the card edge.
## Bug / discrepancy
There are two collapser-card families with inconsistent collapse handling and
"card within card" chrome:
- **`ClideHolderCard`** (lib/builtin/claude/src/holder_card.dart, T-266) — the
group container for Activity (`_ActivityCard`) and Edits (`_EditRunCard`).
Collapse = ticker row + frame background + a header caret. Header order:
`[chevron] summary … [status] [count]`. Status indicator sits INBOARD, left
of the count label. Border/text hardcoded to `panelBorder` / `globalTextMuted`
(no color knob).
- **`ConversationCard`** (lib/builtin/claude/src/conversation_card.dart, T-262)
— the per-tool merged card. Collapse = a single leading caret only (no
background/ticker). Header order: `[caret] label summary/spacer [status]
[actions]`. Has `borderColor` + `accent`.
So the toggle is attached differently, the status mark sits in a different
place, the counter has no fixed slot, and color customization exists on one but
not the other. They should grab the SAME widget (each its own instance — NOT
collapsing across types).
## Proposal — one `ClideCollapserCard` primitive
Both families render through a single collapser primitive. Requirements:
1. **`color` property** drives the border AND the label/text color, so each
instance keeps its visual identity (activity = muted, edits = accent,
error = red, sub-agent, …) through one widget. Default = panelBorder/muted.
2. **Fixed-width counter slot** — the `N steps` / `N edits` count lives in a
right-aligned fixed-width slot so it (and the trailing status icon) never
shift as the number grows or the status appears/changes.
3. **Status icon hard against the card edge** — flip the current
`[count][status]` to `[status]` against the right edge with the counter
inboard of it. The chevron already hugs the LEFT edge. Rule: both icons hug
the card edges; text sits inboard.
4. **Consistent collapse/expand attachment** across both uses (keep the
group-container background-tap-to-collapse for the tail-follow case, D-78,
but the visible control + header layout are identical).
5. Status glyphs: `ClideRunStatus` running (logo-mark spinner ◐) / success ✓ /
error ✕, reusing ClideStatusIndicator.
Nested sub-cards render the SAME primitive (card-within-card consistency).
## Scope
- New `ClideCollapserCard` in lib/widgets/src/ (exported from widgets.dart).
- Migrate `ClideHolderCard` (Activity/Edits) and `ConversationCard` (merged
tool card) onto it; keep per-type content/labels.
- Hold visual fidelity via goldens (conversation_card_goldens_test,
ClideHolderCard golden) + a11y (expanded/collapsed semantics, focusable
toggle).
## Wireframe
docs/design/wireframes/cards/collapser-card.json (+ .png) — collapsed (3 color
variants), expanded with nested sub-cards, annotated for the counter slot +
edge-anchored icons.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 08:15:39', '2026-06-10 08:16:05', NULL, '384d36f5718f573bdaba210b0b8a147f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB16YM1Y910T07Y1Y22MGTEM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Card the ''context'' conversation block like the rest of the cards', 'The injected "context" block (skill-load / command-expansion / system-reminder messages) renders as a frameless `ConversationCardVariant.bare` card in `lib/builtin/claude/src/conversation_view.dart:410-419`, so it reads as a bare `> context …` row wedged between the fully-framed Skill and Bash tool cards (see pasted screenshot). It looks unfinished next to the carded tool calls.
Make the context block follow the standard collapser-card pattern established for all group/tool cards in the `collapser-card` wireframe (docs/design/wireframes/cards/): a proper card frame with the chevron toggle hard against the left edge, label, and the fixed-width right-aligned counter/status slot — so it is visually consistent with the Skill/Bash/Activity cards around it.
Keep the de-emphasis that D-78 calls for (muted accent, collapsed-by-default, first-line summary) — this is about giving it the same *frame* as the other cards, not the blue "you" accent. The same treatment likely applies to the sibling bare cards that share this branch (agent prompt, thinking) for consistency; scope to context first and note whether thinking/agent-prompt should follow.
Acceptance: the context row sits in a framed card matching the collapser-card wireframe geometry, still muted and collapsed by default, with the toggle and counter/status slot aligned to the other cards. Update/extend the relevant golden(s).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 08:17:42', '2026-06-10 08:17:42', NULL, 'b7f1ad221558368a6c547e00043087b7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB170DJA02EH2E8HMH7X7SS4', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'frame0-wireframe skill: pause for user approval after showing each screen', 'The `frame0-wireframe` skill (.claude/skills/frame0-wireframe/SKILL.md) currently runs its Core Workflow straight through — write JSON → push → export → show — and tends to keep generating additional wireframes/screens on its own without stopping. The point of the skill is *dialogue*: each shown screen is a checkpoint for the user to react to, not busywork to batch through.
Adjust the skill so that after step 5 (showing an exported screen inline), it STOPS and waits for explicit user approval/feedback before authoring or exporting the next screen. One screen at a time: show it, ask, then proceed only on a go-ahead. Do not fan out a whole set of wireframes unprompted.
Acceptance: SKILL.md instructs the agent to surface one screen and pause for user approval before continuing; the loop is approval-gated, framing wireframing as a back-and-forth design conversation rather than a one-shot generation run.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 08:17:57', '2026-06-10 08:17:57', NULL, 'e9e59e2c52e643e2c0a2831c90d8cda8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB16FJ7KXGFHQXEG88MYRFTG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Unify collapser cards onto one ClideCollapserCard primitive', 'Unify the conversation-panel collapsible cards onto one ClideCollapserCard primitive, and share card spacing across all card categories. Supersedes ClideHolderCard (T-266) and the collapse logic in ConversationCard (T-262) for the tool path.
## Conversation-panel card model (agreed 2026-06-10)
### Shared spacing — constants, NOT a shared wrapper
All card categories share a small set of spacing CONSTANTS (inter-card bottom gap, inner padding, corner radius). NOT a forced common wrapper widget — each category is its own widget; they just pull the same spacing tokens so the stream reads as one consistent rhythm.
### Three card categories
1. **Dialog cards** — carry the side stripe marking who is speaking (user / Claude / agent). Prose / attribution. Not collapsible. (Today: ConversationCard stripe variant.)
2. **Simple cards** — a single item shown fully open in the stream, never collapses (e.g. the image-show card; more to come). Standalone display: no chevron, no status chrome.
3. **Collapsibles** — the unified collapser. Covers edits, bash, task updates, runs — every tool use. Behaves like the bash card should:
- The whole card is clickable to collapse/expand.
- Collapsed: title = the echoed last content line (like bash now) + an item count + aggregate status (spinner / check / cross).
- Expanded: an inner canvas holding the nested item card(s), each item in its own inner card.
- A single item still gets its own inner card inside the collapser when open, and pushes its status / count / last-line up to the collapser header.
- Inner item cards ALSO show their own per-item status (check / cross / spinner) when expanded; the collapser header carries the aggregate.
- Chrome (per the wireframe): `color` (outer border + chevron / label / text), fixed-width counter slot, status icon hard against the right edge, chevron hard against the left edge.
## Scope
- New `ClideCollapserCard` in lib/widgets/ (exported from widgets.dart) — the category-3 primitive: a list of 1..N inner item cards, collapsed ticker <-> expanded inner canvas, color / fixed-counter / edge-status / edge-chevron chrome, background + caret toggle (D-78 tail-follow), aggregate status + count + echoed-title computed from the items.
- Shared card-spacing constants consumed by all three categories.
- ALL tool uses render as collapsers — single ones as a 1-item list (Bash, Read, Edit, Task, edits runs, activity runs, etc.).
- Inner item cards: content + their own per-item status; no own collapse; no stripe.
- Dialog cards (1) and simple cards (2) are NOT pulled into the collapser — they only adopt the shared spacing constants (keep stripe / inner config).
## Verify
- Goldens regenerated (holder_card, conversation_card_merged) + a11y (expanded / collapsed semantics, focusable toggle).
- Wireframe: docs/design/wireframes/cards/collapser-card.{json,png}.
- After landing: update the ui-design skill''s conversation-panel guidance to describe the three card categories + the collapser.
', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 08:15:39', '2026-06-10 08:43:03', NULL, '444e8c535d796bae0519d4a4761b12e1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5E41Q8TX8X55X4MZ0', 'story', '06FB0TNQM5GSR5WPC2T7CJM8XW', 'editor respects .editorconfig settings', 'When a file is opened in the editor, read .editorconfig from the workspace root and apply: indent_style, indent_size, max_line_length (ruler/wrap guide), end_of_line, trim_trailing_whitespace, insert_final_newline. Parse the INI format ourselves (small, no dep). Glob matching per the EditorConfig spec.
DONE: The editor honours .editorconfig end-to-end. New source-agnostic EditorSettings model (editor_settings.dart) + composition seam (editor_settings_resolver.dart); .editorconfig demoted to a source (editorconfig.dart, own INI parser + glob matcher, root/nearest-wins precedence, no deps). Registry resolves on open and re-resolves open buffers when a .editorconfig is saved in-app (editor.settings-changed; a save hook, not an fs-watch). Editor: Tab/Shift+Tab indent + max_line_length ruler; save applies end_of_line/trim_trailing_whitespace/insert_final_newline. Follow-ups: T-290 (edit from settings panel), T-291 (external fs-watch). 100% coverage on new model/resolver/parser.', 'done', 'medium', NULL, NULL, NULL, '2026-04-22 20:17:26', '2026-06-10 08:57:51', NULL, '4c52cb612afcab62f743c0beae221cbf', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM42VPM50VKAWXBCAM8', 'story', NULL, 'Make links in the Claude conversation panel clickable to open in OS URL handler', 'The Claude conversation panel detects URLs and colorizes them, but they are not interactive. Make detected links clickable (a plain click, or control/cmd-click) so they are handed off to the OS URL opener.
## Behaviour
- Click (or ctrl/cmd-click) on a colorized link opens it via the OS default handler.
- Hover affordance (cursor change / underline) so it reads as clickable.
- Keep the existing colorization.
## Notes
- Honour user/Claude parity (D-6) where relevant.
- Use the platform URL launcher; avoid pulling in an opinionated package if a thin native/url_launcher shim already exists in the tree.
- Guard against non-http schemes / malformed URLs.
DONE 2026-06-09. http(s) links in the conversation open via OsBridge.openURL on click (hover underline + pointer); non-http inert. ClideMarkdown gained an onLinkTap hook + _urlLinkSpan; the 3 inline-interaction callbacks were bundled into ClideMarkdownHooks (single threaded param) — also fixed links/images only working in some markdown contexts. Tests: clide_markdown_test.', 'done', 'medium', NULL, NULL, NULL, '2026-06-06 09:32:53', '2026-06-10 08:57:51', NULL, '599030f7849f46625ecba398407f186d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM531VE0C6N36AT12SR', 'task', NULL, 'Audit widget tests for ultrawide coverage (IDE runs wide)', 'Lesson from T-239: a width-PROPORTIONAL layout bug (status-bar right group drifting to mid-bar via a Spacer-vs-flex split) was invisible at the default 800px flutter_test surface and at 1200px, but glaring at 3440px. clide is an IDE — ultrawide (3440, 5120, ultrawide+vertical splits) is a VERY common real screen size, so width-sensitive layout that only ever gets tested at 800px hides exactly this class of bug.\n\nScope: review the widget/golden suite and add ultrawide coverage where layout is width-sensitive. Candidates: the bottom status bar (done — test/app_statusbar_test.dart now covers 600 + 3440), panel/slot layout + drag-resize (lib/app.dart RootLayout, SlotHost), tab strips (overflow/scroll at wide), ClideMarquee (T-160 — only tested narrow; also check it doesn''t mis-behave wide), conversation view / activity card, the command palette + quick-open overlays (max-width/centering on wide), modal pickers, status items.\n\nApproach: (1) add a shared test helper to pump at a given surface width via tester.view.physicalSize (note: a wide SizedBox under the default 800px surface is CLAMPED — must set view.physicalSize, see test/app_statusbar_test.dart pumpAt). (2) For layout-sensitive widgets, assert key positions/no-overflow at BOTH a normal and an ultrawide width. (3) Don''t blanket-add to every test — target width-sensitive layout (Row/Spacer/Expanded/Flexible/Align, max-width caps, centering). Note any widget that SHOULD cap/center on ultrawide (readability) vs fill. Relates to Q-26 (small-screen layout) — same responsive concern at the other end.
DONE 2026-06-09. Added the reusable setSurfaceSize(tester, width) helper to test/helpers/widget_harness.dart (the foundation the ticket asked for). Ultrawide (3440) cases added on the flagged width-sensitive surfaces: ClideMarquee (was narrow-only — now verified static when a line fits a wide slot) and the quick-open palette (verified width-capped, not stretched). Status bar already covers 600+3440 (T-239). AUDIT NOTES on the rest: overlays/pickers use fixed-width panels (ClideMenu/quick-open 480, anchored popovers) so they''re inherently capped/centered; tab strips (MultitabPane) and status items overflow only when NARROW (a small-screen concern, Q-26), not wide; RootLayout slots are explicit fixed/flex widths with drag-resize. The helper is now in place for any future width-sensitive widget. Not exhaustive by design (audit + reusable tooling + the high-risk surfaces).', 'done', 'medium', NULL, NULL, 'D-23', '2026-06-05 10:02:31', '2026-06-10 08:57:51', NULL, '8e987583d005fafba43bb452cadf1dbd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4GN07FN8GAXZ20YAR', 'story', '06FB0TNQM7W0NPAEG3KWNFY9DR', 'Render pasted-image @path tokens as thumbnails in the message log', 'When a user message contains a pasted-screenshot reference — an @<path> token pointing at an image (the composer''s ComposerAttachment.pathToken format, e.g. @/home/<user>/.cache/clide/pasted/paste-<ts>.png, see screenshot) — the conversation log renders it as the raw path string. Show it as an inline THUMBNAIL instead.
The composer already renders pre-send attachments as Image.file thumbnail chips (clipboard_paste.dart + claude_composer _chip); this carries that into the post-send message log (UserMessage rendering in conversation_view/conversation_card).
Scope:
- When rendering UserMessage text, detect @<path> tokens; for image paths (reuse the extension check behind _looksLikeImage: .png/.jpg/.jpeg/.gif/.webp/.bmp), render a bounded Image.file thumbnail in place of the bare token. Surrounding prose still renders normally; a message can carry multiple tokens.
- Click/activate the thumbnail to open the full-size image (a preview dialog). Keyboard-operable + a11y label (filename).
- Graceful fallback: a missing/unreadable file (pasted temp files can be cleaned up) degrades to a small placeholder or the path text via Image.file errorBuilder — never a crash/exception.
- Non-image @path tokens MAY render as a file chip (filename + icon) like the composer, but image thumbnails are the focus.
- Note: Image.file reads the path directly via dart:io, so this is NOT gated by the files.read allow-list (D-80) — the cache dir is outside the workspace and that''s fine for display.
- Display-only: the text actually sent to Claude is unchanged; this only affects rendering.
Acceptance:
1. A user message with an @<path> image token shows an inline thumbnail in the log (not the raw path).
2. Clicking/activating the thumbnail opens the full image.
3. A missing/unreadable referenced file degrades to a placeholder (or the path text), no exception.
4. Prose and any non-image @path tokens around it still render readably; multiple tokens in one message all resolve.
5. The message content delivered to Claude is unchanged (render-only). Relates to T-142 (paste attachments).
MERGED T-254 (duplicate) into this ticket. Extra nuance carried over from T-254: keep the file path available (e.g. as a small caption/subtitle under the thumbnail, or via the lightbox) so it can still be copied/referenced. Reuse the existing inline image card + lightbox (T-252) rather than a new component.
RETRACTED the earlier ''merged T-254'' note — T-236 and T-254 are distinct tickets (per user); disregard that note. T-236 stays scoped to its own description.
DONE 2026-06-09 — implemented as the hybrid (D-89): inline thumbnail in the message prose that opens the lightbox on click/Enter. New ImageThumbnail + openImageLightbox (lib/builtin/claude/src/image_thumbnail.dart); ClideMarkdown gained an onImageToken WidgetSpan seam. Missing file → placeholder; multiple tokens; non-image @path stays text; render-only (copyText unchanged). Composer chips reuse the same 44px thumbnail. Tests: image_thumbnail_test + conversation_view_test cases. Closes alongside T-254.', 'done', 'medium', NULL, NULL, NULL, '2026-06-03 15:31:01', '2026-06-10 08:57:51', NULL, '8eb9013e34cd82b993d06b6b15eacf40', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4BD8PXDRHBF001200', 'bug', NULL, 'Flaky: pty session_test ''write sends keystrokes to child'' starves under parallel/coverage load', 'test/pty/session_test.dart:47 ''write sends keystrokes to child'' (and its sibling pty integration tests) intermittently fail when run in a large parallel pool — observed twice during T-29: once in a bare ''flutter test --coverage'' (whole suite, max parallelism + coverage instrumentation) and once mid pre-push gate (''+570 -1''). Passes reliably in isolation and via the gate''s SERIAL test-core pass (''dart test test/pty'', +571 ok), so it is not a logic bug.
Root cause: these tests spawn a real /bin/sh via NativePty and wait on the reader ISOLATE to deliver bytes. Under heavy CPU contention the reader isolate is starved and the 20s ioTimeout (test/helpers/timeouts.dart) elapses before the shell''s first byte / echo response is delivered. Already hardened once (T-108 swapped wall-clock sleeps for event-driven waits; first-byte Completer + 25ms-poll _waitForBuffer), but the isolate-scheduling assumption still breaks under load.
These tests are tagged ''pty'' and are meant to run in the gate''s serial pass. Fix options: (a) ensure the parallel coverage pass EXCLUDES tag:pty (run pty only in the serial pass) so they never compete for isolate scheduling — verify ci/test.sh / dart_test.yaml tag routing; (b) harden against isolate starvation (e.g. give the reader isolate priority, or assert on delivery via a more robust signal); (c) document that ''flutter test --coverage'' over the whole tree is not a supported invocation (use make test + the gate). Prefer (a). Refs: T-108, T-96 (reader-isolate hangs), T-192 (gate parallel/serial passes), D-23 (test pyramid).
FIXED 2026-06-09 (commit 0231cb4). Root cause: ci/test_core.sh ran ''dart test test/ipc test/pty ... test/pql'' in the DEFAULT parallel pool, so test/pty''s real-PTY tests contended for fds+CPU with the other core suites and the reader isolate was starved. (ci/test.sh already isolated pty via --concurrency=1 --tags pty; test_core.sh was the gap.) My manual ''flutter test --coverage'' repro was a separate wrong-invocation artifact — the flutter runner can''t reliably deliver the PTY master fd, which is why the gate excludes pty from the flutter pool entirely.) Fix: test_core.sh now runs a serial ''--concurrency=1 --tags pty'' pass + a parallel ''--exclude-tags pty'' pass, mirroring ci/test.sh. Verified: pty pass +5 stable across repeated runs; full core +571 unchanged. Option (a) from the ticket.', 'done', 'medium', NULL, NULL, 'D-23', '2026-06-09 14:57:32', '2026-06-10 08:57:51', NULL, 'd6e4b6820ef300201b2bea67d965f90d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM61SRVBDRFFN2S80T4', 'story', NULL, 'Render pasted images via the image viewer card in the Claude conversation panel instead of echoing only the file path', 'When the user pastes an image, the conversation panel echoes back the file path as plain text (e.g. @/home/.../paste-<ts>.png). We now have an image viewer card — render the pasted image inline using that card instead of (or in addition to) the bare path.
## Behaviour
- Detect a pasted-image path in the conversation stream and render the image viewer card.
- Still surface the file path (e.g. as a caption / subtitle on the card) so it can be copied/referenced.
- Reuse the existing image viewer card component rather than building a new one.
## Notes
- Applies to the Claude conversation panel rendering path.
- Consider failure cases: missing/deleted file, non-image paste, very large images.
CANCELLED as a duplicate of T-236 (2026-06-09). T-236 is the concrete spec for rendering pasted @path images inline in the Claude conversation; its scope now includes T-254''s path-as-caption nuance. Implement under T-236.
RETRACTED the cancellation — NOT a duplicate of T-236 (per user, 2026-06-09). Both stay open. T-254 remains its own ticket.
DONE 2026-06-09 — satisfied by D-89 together with T-236. The lightbox-expansion + path-retention (filename a11y label / unchanged copyText) intent from T-254 is delivered by the shared ImageThumbnail → lightbox; the chosen presentation is inline (not a separate card) per the user. See T-236 / D-89 / commit.', 'done', 'medium', NULL, NULL, NULL, '2026-06-06 09:32:54', '2026-06-10 08:57:51', NULL, 'e34e357b7085fa01d97f4a33d99ca86a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM42WW0SY3XEXKE8PK8', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Chosen theme is not persisted per-repo across loads', 'Picking a theme (status-bar switcher T-234, or the Settings modal) applies live but is never saved, so every reload resets to the first bundled theme. The choice should persist PER REPO.
Root cause: ThemeController.select() (lib/kernel/src/theme/controller.dart:43) only mutates _currentName + notifies — no persistence. It is constructed in facade.dart:160 as ThemeController(bundled: bundledThemes) with no initialName, so on boot it always falls back to bundled.first.
Infra already exists — no new store needed: SettingsStore (lib/kernel/src/settings.dart) has a PROJECT scope that persists to <repo>/.clide/settings.yaml via ''project.*'' keys, and ProjectManager.openProject() already calls settings.setProjectDir() on open (project.dart:130), which loads that file.
Proposed fix:
1. PERSIST on change — wire a listener on ThemeController (in facade.dart, where both theme + settings exist) that writes the current theme to settings whenever currentName changes; use project scope (''project.theme'') when a repo is open. currentName already encodes the high-contrast variant (resolveThemeName -> ''<base>-hc''), so the HC toggle persists for free.
2. RESTORE on load — the project dir is set AFTER boot, so restoration must hook project-open (ProjectManager.openProject / settings.setProjectDir), not just construction: when a project opens, read ''project.theme'' and, if present + known, theme.select() it. Guard ArgumentError for a theme that is no longer bundled (fall back to default, don''t throw).
Open question: app-scoped fallback. Persist project-scoped per the report; optionally also write ''app.theme'' as the global default so a brand-new repo inherits the last choice instead of bundled.first. Decide during implementation.
Acceptance: pick a theme (incl. the High-contrast toggle) -> restart -> same theme restored for that repo; two repos keep independent choices; with no repo open, the app default applies; a saved theme that no longer exists degrades to the default without throwing.
Tests: SettingsStore round-trips ''project.theme''; opening a project applies the saved theme to the controller; select() persists; unknown saved theme falls back. Refs: T-234 (theme switcher), T-288/D-88 (picker), SettingsStore, ProjectManager.
FIXED 2026-06-09 (this commit). Added lib/kernel/src/theme/theme_persistence.dart + wireThemePersistence() in facade. Decision on the open question: persist BOTH project.theme (per-repo, in .clide/settings.yaml) AND app.theme (global default) — so a themed repo keeps its choice, an unthemed/new repo inherits the last global choice, and the HC variant persists (name encodes -hc). Restore prefers project over app; unknown theme is ignored (no throw). Tests: test/kernel/src/theme_persistence_test.dart (6 cases — persist app/project, HC, restore-on-open, boot restore, unknown fallback). All green.', 'done', 'medium', NULL, NULL, NULL, '2026-06-09 15:12:15', '2026-06-10 08:57:51', NULL, 'f71e40ac7f3e0a8dd2e52366b1ff5cb9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB16FJ7KXGFHQXEG88MYRFTG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Unify collapser cards onto one ClideCollapserCard primitive', 'Unify the conversation-panel collapsible cards onto one ClideCollapserCard primitive, and share card spacing across all card categories. Supersedes ClideHolderCard (T-266) and the collapse logic in ConversationCard (T-262) for the tool path.
## Conversation-panel card model (agreed 2026-06-10)
### Shared spacing — constants, NOT a shared wrapper
All card categories share a small set of spacing CONSTANTS (inter-card bottom gap, inner padding, corner radius). NOT a forced common wrapper widget — each category is its own widget; they just pull the same spacing tokens so the stream reads as one consistent rhythm.
### Three card categories
1. **Dialog cards** — carry the side stripe marking who is speaking (user / Claude / agent). Prose / attribution. Not collapsible. (Today: ConversationCard stripe variant.)
2. **Simple cards** — a single item shown fully open in the stream, never collapses (e.g. the image-show card; more to come). Standalone display: no chevron, no status chrome.
3. **Collapsibles** — the unified collapser. Covers edits, bash, task updates, runs — every tool use. Behaves like the bash card should:
- The whole card is clickable to collapse/expand.
- Collapsed: title = the echoed last content line (like bash now) + an item count + aggregate status (spinner / check / cross).
- Expanded: an inner canvas holding the nested item card(s), each item in its own inner card.
- A single item still gets its own inner card inside the collapser when open, and pushes its status / count / last-line up to the collapser header.
- Inner item cards ALSO show their own per-item status (check / cross / spinner) when expanded; the collapser header carries the aggregate.
- Chrome (per the wireframe): `color` (outer border + chevron / label / text), fixed-width counter slot, status icon hard against the right edge, chevron hard against the left edge.
## Scope
- New `ClideCollapserCard` in lib/widgets/ (exported from widgets.dart) — the category-3 primitive: a list of 1..N inner item cards, collapsed ticker <-> expanded inner canvas, color / fixed-counter / edge-status / edge-chevron chrome, background + caret toggle (D-78 tail-follow), aggregate status + count + echoed-title computed from the items.
- Shared card-spacing constants consumed by all three categories.
- ALL tool uses render as collapsers — single ones as a 1-item list (Bash, Read, Edit, Task, edits runs, activity runs, etc.).
- Inner item cards: content + their own per-item status; no own collapse; no stripe.
- Dialog cards (1) and simple cards (2) are NOT pulled into the collapser — they only adopt the shared spacing constants (keep stripe / inner config).
## Verify
- Goldens regenerated (holder_card, conversation_card_merged) + a11y (expanded / collapsed semantics, focusable toggle).
- Wireframe: docs/design/wireframes/cards/collapser-card.{json,png}.
- After landing: update the ui-design skill''s conversation-panel guidance to describe the three card categories + the collapser.
', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 08:15:39', '2026-06-10 09:13:08', NULL, 'f15f786bf4e7f70a427368d3dd5b96aa', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1Q1Y3CYJHD7W5F68VDB6T4', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Surface Claude''s task list docked above the bottom input pane', 'Claude maintains a task/todo list during a session (the TodoWrite / TaskCreate checklist), but clide never shows it — it is completely invisible to the user. The transcript reader (lib/builtin/claude/src/transcript_reader.dart:205-212) skips the relevant event types and there is no model class for task items; the conversation view (conversation_view.dart) renders none. So the user has no view of what Claude is tracking or how far along a multi-step task is.
Surface it as a compact, always-current task list DOCKED to the bottom input pane of the Claude conversation view. Per the Explore: the pane is a Column in claude_pane.dart (_ClaudePaneState.build, ~line 474-542) with ConversationView (Expanded) on top and the ToolPromptCard/ClaudeComposer at the bottom (~line 519-525); the task list should sit between them — directly above the composer (claude_composer.dart), non-expanded — so it stays pinned and visible as Claude works.
Scope:
- PARSE: model Claude''s task list from the transcript. Find the event type carrying the todo/task items (currently among the skipped types in transcript_reader.dart) and add a TaskList model + items (text + status: pending/in_progress/completed). Latest-write-wins — the list reflects the most recent todo state, not an append log.
- RENDER: a docked task-list widget above the composer. Compact: collapsed/summary by default (e.g. ''N tasks · M done'' + current in_progress item), expandable to the full checklist. Per-item status glyph (pending/in-progress/done) using existing tokens + Phosphor icons (ui-design skill). Hide entirely when there are no tasks.
- Keep it display-only and de-emphasised so it never competes with the composer for attention; honour D-78 (interaction zone) — this is a display surface, not an interactive control.
- a11y: list semantics, status announced per item.
Acceptance:
1. When Claude has an active task list, it shows docked directly above the input pane and updates live as items change state.
2. Empty/no-task sessions show nothing (no empty chrome).
3. Collapsed by default with a one-line summary; expandable to the full list.
4. Display-only; the composer keeps full function and focus behaviour.
5. Tests: transcript_reader parses the task-list event into the model (incl. status transitions + latest-wins); widget renders states + collapse/expand + hidden-when-empty.
Refs: conversation card patterns (conversation_card.dart), D-78 (interaction zone), ui-design skill (tokens/icons).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 09:28:04', '2026-06-10 09:28:04', NULL, 'aa6646cde3277aabd22519d1b35e48c0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1S7613SYF0M9XQT5JNWM40', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Lightbox: click anywhere outside the image should dismiss', 'When the image lightbox is open, clicking outside the image should close it, matching Esc and the close (x) button.
Today only the thin margin around the lightbox dismisses. The DialogRouter host (lib/kernel/src/dialog.dart:95-103) renders a backdrop that dismisses on tap, but wraps the dialog content in a tap-swallowing GestureDetector (onTap: () {}). ClideLightbox fills a 94%-of-screen SizedBox whose Positioned.fill InteractiveViewer (lib/widgets/src/clide_lightbox.dart:93-113) covers the entire box. So taps on the dark area beside a smaller-than-94% image hit the swallower, not the backdrop, and nothing happens.
Fix: make taps that land outside the actual image bounds dismiss — e.g. add an onTap to the lightbox''s outer GestureDetector that calls onDismiss when the tap is not on the image (InteractiveViewer child), rather than relying on the host backdrop only reaching the 6% margin. Preserve double-tap-to-reset, scroll-to-zoom, and pan; a single tap on empty canvas closes, a tap/drag on the image does not.
Acceptance: single click on the dimmed area surrounding the image closes the lightbox; clicking/dragging/zooming the image does not; Esc and the x still close.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-10 09:37:31', '2026-06-10 09:37:31', NULL, '0063d28d4e7b97a9caef8b082a0a6a71', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1SJKA7KRCQMZE41SPQG780', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Prompt card: numpad digits should select options, not just the number row', 'The Claude prompt card (permission / AskUserQuestion) number-key shortcuts only respond to the top number-row digits, not the numeric keypad. Users on a full keyboard expect numpad 1-9 to pick a button/option too.
Root cause: lib/builtin/claude/src/prompt_card.dart:125-135 defines _digitKeys as LogicalKeyboardKey.digit1..digit9 only. _onKey (line 146) matches against that list, so numpad1..numpad9 fall through to ignored. Note that numpadEnter is already handled alongside enter at line 143 — numpad parity is partial.
Fix: extend _digitKeys (or the match in _onKey) to also map LogicalKeyboardKey.numpad1..numpad9 to the same 1-9 selection. Keep the hasPrimaryFocus / modifier guards as-is so digits still type into note fields.
Acceptance: with the prompt card focused, numpad 1-9 selects the corresponding option/button exactly like the number-row digits; numpad keys still type normally when a note field has focus.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-10 09:39:05', '2026-06-10 09:39:05', NULL, '85f9ce66179451f329a97211dbb96c86', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB16FJ7KXGFHQXEG88MYRFTG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Unify collapser cards onto one ClideCollapserCard primitive', 'Unify the conversation-panel collapsible cards onto one ClideCollapserCard primitive, and share card spacing across all card categories. Supersedes ClideHolderCard (T-266) and the collapse logic in ConversationCard (T-262) for the tool path.
## Conversation-panel card model (agreed 2026-06-10)
### Shared spacing — constants, NOT a shared wrapper
All card categories share a small set of spacing CONSTANTS (inter-card bottom gap, inner padding, corner radius). NOT a forced common wrapper widget — each category is its own widget; they just pull the same spacing tokens so the stream reads as one consistent rhythm.
### Three card categories
1. **Dialog cards** — carry the side stripe marking who is speaking (user / Claude / agent). Prose / attribution. Not collapsible. (Today: ConversationCard stripe variant.)
2. **Simple cards** — a single item shown fully open in the stream, never collapses (e.g. the image-show card; more to come). Standalone display: no chevron, no status chrome.
3. **Collapsibles** — the unified collapser. Covers edits, bash, task updates, runs — every tool use. Behaves like the bash card should:
- The whole card is clickable to collapse/expand.
- Collapsed: title = the echoed last content line (like bash now) + an item count + aggregate status (spinner / check / cross).
- Expanded: an inner canvas holding the nested item card(s), each item in its own inner card.
- A single item still gets its own inner card inside the collapser when open, and pushes its status / count / last-line up to the collapser header.
- Inner item cards ALSO show their own per-item status (check / cross / spinner) when expanded; the collapser header carries the aggregate.
- Chrome (per the wireframe): `color` (outer border + chevron / label / text), fixed-width counter slot, status icon hard against the right edge, chevron hard against the left edge.
## Scope
- New `ClideCollapserCard` in lib/widgets/ (exported from widgets.dart) — the category-3 primitive: a list of 1..N inner item cards, collapsed ticker <-> expanded inner canvas, color / fixed-counter / edge-status / edge-chevron chrome, background + caret toggle (D-78 tail-follow), aggregate status + count + echoed-title computed from the items.
- Shared card-spacing constants consumed by all three categories.
- ALL tool uses render as collapsers — single ones as a 1-item list (Bash, Read, Edit, Task, edits runs, activity runs, etc.).
- Inner item cards: content + their own per-item status; no own collapse; no stripe.
- Dialog cards (1) and simple cards (2) are NOT pulled into the collapser — they only adopt the shared spacing constants (keep stripe / inner config).
## Verify
- Goldens regenerated (holder_card, conversation_card_merged) + a11y (expanded / collapsed semantics, focusable toggle).
- Wireframe: docs/design/wireframes/cards/collapser-card.{json,png}.
- After landing: update the ui-design skill''s conversation-panel guidance to describe the three card categories + the collapser.
', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 08:15:39', '2026-06-10 09:39:52', NULL, 'd173d4e6a58bb9dba5ad279d705f083b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB170DJA02EH2E8HMH7X7SS4', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'frame0-wireframe skill: pause for user approval after showing each screen', 'The `frame0-wireframe` skill (.claude/skills/frame0-wireframe/SKILL.md) currently runs its Core Workflow straight through — write JSON → push → export → show — and tends to keep generating additional wireframes/screens on its own without stopping. The point of the skill is *dialogue*: each shown screen is a checkpoint for the user to react to, not busywork to batch through.
Adjust the skill so that after step 5 (showing an exported screen inline), it STOPS and waits for explicit user approval/feedback before authoring or exporting the next screen. One screen at a time: show it, ask, then proceed only on a go-ahead. Do not fan out a whole set of wireframes unprompted.
Acceptance: SKILL.md instructs the agent to surface one screen and pause for user approval before continuing; the loop is approval-gated, framing wireframing as a back-and-forth design conversation rather than a one-shot generation run.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 08:17:57', '2026-06-10 09:41:52', NULL, 'ed51880a7f0f3c9f29949d91d4c4ad32', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7H8JWCP59WQVD0FW4', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'Frame0 wireframe: settings screen design', 'Design the app''s settings screen as a Frame0 wireframe before building `builtin.settings-ui` (see Tier 6 epic T-8). Output is a wireframe to align on layout/IA, not implementation.
**Deliverable**
- Frame0 wireframe authored via the frame0-wireframe skill: local JSON source-of-truth synced to Frame0, exported for review.
- Covers the settings screen shell + at least one fully-rendered category so the form-field patterns are concrete.
**Scope to frame (from T-8)**
- Schema-driven panel: form fields keyed off the schema each subsystem registers against the kernel SettingsStore; edits write back to `.clide/settings.yaml`.
- Navigation/IA: how categories are grouped and selected (sidebar list? sections? search?).
- Field types to mock: toggle, enum/select (e.g. keymap preset), text/number, and a ''opens external file'' affordance (e.g. editor `.editorconfig` per T-290).
- Known consumers to account for: keymap preset switching (T-115/T-64/T-65/T-66), editor settings (T-290), activity fold level (T-183), theme picker (Tier 6 theming UI).
**Open design questions for the wireframe to answer**
- Settings as a full-screen view, a pane/tab, or a modal?
- Per-project (`.clide/settings.yaml`) vs. user-global scope — shown together or switched?
- Search/filter across all settings.
- How schema-driven fields render labels, help text, defaults, and reset.
**Constraints**
- Follow clide visual language — pull theme tokens / control geometry from the ui-design skill so the wireframe maps cleanly to real widgets (no Material/Cupertino).
This is the design step; implementation of the actual settings UI is separate child work under T-8.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-09 20:24:54', '2026-06-10 09:42:26', NULL, '6c742b5a63eeb4f907a7f5aa41a98384', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1XDWKQ594ET4GDYEFK5ZJ4', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Permission card: add ''Deny & simplify'' option that denies with a retry-simpler note', 'Add a fourth option to the Claude permission prompt card (lib/builtin/claude/src/prompt_card.dart) alongside Allow / Allow & don''t ask again / Deny.
WHAT IT DOES
A deny that carries a preformatted follow-up note telling Claude the action was too complex for the permission system and to retry in a simpler format. Implemented as a _permDeny variant that passes a fixed note into DenyTool (today _permDeny uses _permNote() ?? ''Denied by the user.'' at prompt_card.dart:202). The user''s own note field, if filled, should still be respected — append it to / combine with the preformatted text rather than discard it.
LABEL: ''Deny & simplify'' (working label; placed after Deny).
TOOLTIP: ClideButton already supports (clide_button.dart:26) — add a mouseover explaining the behavior, e.g. ''Deny and ask Claude to retry this action in a simpler format — complex interactions don''t work well with the permission system.''
PREFORMATTED DENY NOTE (workshop wording, starting point):
"Denied — this action is too complex for the permission system to approve cleanly. Please retry with a simpler, more granular approach (break it into smaller steps or use a plainer command) to avoid this permission prompt. This is a one-off for THIS action only: do not add a memory and do not change permission settings — just reformulate and try again."
The ''do not add a memory / do not change permission settings'' clause is deliberate: without it Claude tends to ''fix'' the permission system (writing memories, rewriting permission config), which means continuous fiddling with a surface we don''t want it touching.
NUMBER-KEY SLOT
_activateNumber (prompt_card.dart:161-164) maps 1=Allow, 2=Allow&remember (when canRemember), 3/2=Deny. Add the new option as the next index (4 when canRemember, else 3). Keep Deny as its own option; the new one is additive. Update the digit/numpad shortcut mapping accordingly (see also T-310 numpad parity).
DESIGN NOTE — escalation behavior
The deny note enters the conversation and stays in context, so repeated use within one session compounds (Claude leans progressively harder toward simpler formats). That''s largely the intended escalating pressure, but the note is phrased as a one-shot ''retry THIS action'' rather than a standing rule to limit over-correction. Worth watching in testing whether repeated denials over-bias toward trivial formats.
ACCEPTANCE
- A fourth button ''Deny & simplify'' appears on the permission card with a tooltip.
- Activating it resolves the prompt as a deny whose note is the preformatted retry-simpler text (with the user''s typed note appended when present).
- The note explicitly tells Claude not to add a memory or change permission settings.
- Number-row and numpad digit shortcuts address the new option in the correct slot.
- Widget test covers the new button resolving to a DenyTool with the expected note.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 09:55:55', '2026-06-10 09:55:55', NULL, '74171c90e0e0bda3be6590e1e7e536dd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1XDWKQ594ET4GDYEFK5ZJ4', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Permission card: add ''Deny & simplify'' option that denies with a retry-simpler note', 'Add a fourth option to the Claude permission prompt card (lib/builtin/claude/src/prompt_card.dart) alongside Allow / Allow & don''t ask again / Deny.
WHAT IT DOES
A deny that carries a preformatted follow-up note telling Claude the action was too complex for the permission system and to retry in a simpler format. Implemented as a _permDeny variant that passes a fixed note into DenyTool (today _permDeny uses _permNote() ?? ''Denied by the user.'' at prompt_card.dart:202). The user''s own note field, if filled, should still be respected — append it to / combine with the preformatted text rather than discard it.
LABEL: ''Deny & simplify'' (working label; placed after Deny).
TOOLTIP: ClideButton already supports (clide_button.dart:26) — add a mouseover explaining the behavior, e.g. ''Deny and ask Claude to retry this action in a simpler format — complex interactions don''t work well with the permission system.''
PREFORMATTED DENY NOTE (workshop wording, starting point):
"Denied — this action is too complex for the permission system to approve cleanly. Please retry with a simpler, more granular approach (break it into smaller steps or use a plainer command) to avoid this permission prompt. This is a one-off for THIS action only: do not add a memory and do not change permission settings — just reformulate and try again."
The ''do not add a memory / do not change permission settings'' clause is deliberate: without it Claude tends to ''fix'' the permission system (writing memories, rewriting permission config), which means continuous fiddling with a surface we don''t want it touching.
NUMBER-KEY SLOT
_activateNumber (prompt_card.dart:161-164) maps 1=Allow, 2=Allow&remember (when canRemember), 3/2=Deny. Add the new option as the next index (4 when canRemember, else 3). Keep Deny as its own option; the new one is additive. Update the digit/numpad shortcut mapping accordingly (see also T-310 numpad parity).
DESIGN NOTE — escalation behavior
The deny note enters the conversation and stays in context, so repeated use within one session compounds (Claude leans progressively harder toward simpler formats). That''s largely the intended escalating pressure, but the note is phrased as a one-shot ''retry THIS action'' rather than a standing rule to limit over-correction. Worth watching in testing whether repeated denials over-bias toward trivial formats.
ACCEPTANCE
- A fourth button ''Deny & simplify'' appears on the permission card with a tooltip.
- Activating it resolves the prompt as a deny whose note is the preformatted retry-simpler text (with the user''s typed note appended when present).
- The note explicitly tells Claude not to add a memory or change permission settings.
- Number-row and numpad digit shortcuts address the new option in the correct slot.
- Widget test covers the new button resolving to a DenyTool with the expected note.
CLARIFICATION (the TOOLTIP line above lost a word to shell escaping): ClideButton already exposes a tooltip parameter (clide_button.dart:17,26,42) — pass tooltip on the new button for the mouseover; no widget change needed.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 09:55:55', '2026-06-10 09:56:24', NULL, '43ec3a8962c96601978154295a38cebb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1SJKA7KRCQMZE41SPQG780', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Prompt card: numpad digits should select options, not just the number row', 'The Claude prompt card (permission / AskUserQuestion) number-key shortcuts only respond to the top number-row digits, not the numeric keypad. Users on a full keyboard expect numpad 1-9 to pick a button/option too.
Root cause: lib/builtin/claude/src/prompt_card.dart:125-135 defines _digitKeys as LogicalKeyboardKey.digit1..digit9 only. _onKey (line 146) matches against that list, so numpad1..numpad9 fall through to ignored. Note that numpadEnter is already handled alongside enter at line 143 — numpad parity is partial.
Fix: extend _digitKeys (or the match in _onKey) to also map LogicalKeyboardKey.numpad1..numpad9 to the same 1-9 selection. Keep the hasPrimaryFocus / modifier guards as-is so digits still type into note fields.
Acceptance: with the prompt card focused, numpad 1-9 selects the corresponding option/button exactly like the number-row digits; numpad keys still type normally when a note field has focus.', 'ready', 'low', NULL, NULL, NULL, '2026-06-10 09:39:05', '2026-06-10 10:01:09', NULL, '5e2f8dfdf163e310d8bc479165ce5130', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1ZFJK6J2GSV4SA69QF730C', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Image card caches by path — overwritten file shows stale render', 'clide image show (and the conversation image viewer card) appears to cache by file path: re-showing a file after its bytes change at the SAME path renders the previous image, not the new one. Hit live while iterating on a Frame0 wireframe (re-exported settings-modal.png in place; the pane kept showing the prior version until exported under a new filename).
Expected: showing a path always reflects the current file bytes.
Explore / options:
- Cache-key on mtime + size (or a content hash) instead of path alone; invalidate when they change.
- A force-refresh path for clide image show (re-decode, bypass cache).
- Check where the cache lives: the image card widget (Image.file / ImageProvider cache — Flutter''s imageCache keys by path+scale and won''t refresh on overwrite), the IPC image.show handler, or the pane render layer.
- Likely Flutter imageCache: evict the key (or use a FileImage with a key that includes mtime) before showing.
Note: surfaced via clide image show (T-249 / T-252 lightbox share the same provider). Repro: export an image, show it, overwrite the same path with different content, show again -> stale.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 10:04:53', '2026-06-10 10:04:53', NULL, '9179616adf1829faafb62ff32b7ead0a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1SJKA7KRCQMZE41SPQG780', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Prompt card: numpad digits should select options, not just the number row', 'The Claude prompt card (permission / AskUserQuestion) number-key shortcuts only respond to the top number-row digits, not the numeric keypad. Users on a full keyboard expect numpad 1-9 to pick a button/option too.
Root cause: lib/builtin/claude/src/prompt_card.dart:125-135 defines _digitKeys as LogicalKeyboardKey.digit1..digit9 only. _onKey (line 146) matches against that list, so numpad1..numpad9 fall through to ignored. Note that numpadEnter is already handled alongside enter at line 143 — numpad parity is partial.
Fix: extend _digitKeys (or the match in _onKey) to also map LogicalKeyboardKey.numpad1..numpad9 to the same 1-9 selection. Keep the hasPrimaryFocus / modifier guards as-is so digits still type into note fields.
Acceptance: with the prompt card focused, numpad 1-9 selects the corresponding option/button exactly like the number-row digits; numpad keys still type normally when a note field has focus.', 'done', 'low', NULL, NULL, NULL, '2026-06-10 09:39:05', '2026-06-10 10:07:19', NULL, '229d4e82ae8b66e249475f49d69bacdb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1ZFJK6J2GSV4SA69QF730C', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Image card caches by path — overwritten file shows stale render', 'clide image show (and the conversation image viewer card) appears to cache by file path: re-showing a file after its bytes change at the SAME path renders the previous image, not the new one. Hit live while iterating on a Frame0 wireframe (re-exported settings-modal.png in place; the pane kept showing the prior version until exported under a new filename).
Expected: showing a path always reflects the current file bytes.
Explore / options:
- Cache-key on mtime + size (or a content hash) instead of path alone; invalidate when they change.
- A force-refresh path for clide image show (re-decode, bypass cache).
- Check where the cache lives: the image card widget (Image.file / ImageProvider cache — Flutter''s imageCache keys by path+scale and won''t refresh on overwrite), the IPC image.show handler, or the pane render layer.
- Likely Flutter imageCache: evict the key (or use a FileImage with a key that includes mtime) before showing.
Note: surfaced via clide image show (T-249 / T-252 lightbox share the same provider). Repro: export an image, show it, overwrite the same path with different content, show again -> stale.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 10:04:53', '2026-06-10 10:08:59', NULL, '92b12d703dccdb13e19da14a9d051e6b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB16YM1Y910T07Y1Y22MGTEM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Card the ''context'' conversation block like the rest of the cards', 'The injected "context" block (skill-load / command-expansion / system-reminder messages) renders as a frameless `ConversationCardVariant.bare` card in `lib/builtin/claude/src/conversation_view.dart:410-419`, so it reads as a bare `> context …` row wedged between the fully-framed Skill and Bash tool cards (see pasted screenshot). It looks unfinished next to the carded tool calls.
Make the context block follow the standard collapser-card pattern established for all group/tool cards in the `collapser-card` wireframe (docs/design/wireframes/cards/): a proper card frame with the chevron toggle hard against the left edge, label, and the fixed-width right-aligned counter/status slot — so it is visually consistent with the Skill/Bash/Activity cards around it.
Keep the de-emphasis that D-78 calls for (muted accent, collapsed-by-default, first-line summary) — this is about giving it the same *frame* as the other cards, not the blue "you" accent. The same treatment likely applies to the sibling bare cards that share this branch (agent prompt, thinking) for consistency; scope to context first and note whether thinking/agent-prompt should follow.
Acceptance: the context row sits in a framed card matching the collapser-card wireframe geometry, still muted and collapsed by default, with the toggle and counter/status slot aligned to the other cards. Update/extend the relevant golden(s).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 08:17:42', '2026-06-10 10:09:11', NULL, '647c5ca8b442dd1f86336889e054f779', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7H8JWCP59WQVD0FW4', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'Frame0 wireframe: settings screen design', 'Design the app''s settings screen as a Frame0 wireframe before building `builtin.settings-ui` (see Tier 6 epic T-8). Output is a wireframe to align on layout/IA, not implementation.
**Deliverable**
- Frame0 wireframe authored via the frame0-wireframe skill: local JSON source-of-truth synced to Frame0, exported for review.
- Covers the settings screen shell + at least one fully-rendered category so the form-field patterns are concrete.
**Scope to frame (from T-8)**
- Schema-driven panel: form fields keyed off the schema each subsystem registers against the kernel SettingsStore; edits write back to `.clide/settings.yaml`.
- Navigation/IA: how categories are grouped and selected (sidebar list? sections? search?).
- Field types to mock: toggle, enum/select (e.g. keymap preset), text/number, and a ''opens external file'' affordance (e.g. editor `.editorconfig` per T-290).
- Known consumers to account for: keymap preset switching (T-115/T-64/T-65/T-66), editor settings (T-290), activity fold level (T-183), theme picker (Tier 6 theming UI).
**Open design questions for the wireframe to answer**
- Settings as a full-screen view, a pane/tab, or a modal?
- Per-project (`.clide/settings.yaml`) vs. user-global scope — shown together or switched?
- Search/filter across all settings.
- How schema-driven fields render labels, help text, defaults, and reset.
**Constraints**
- Follow clide visual language — pull theme tokens / control geometry from the ui-design skill so the wireframe maps cleanly to real widgets (no Material/Cupertino).
This is the design step; implementation of the actual settings UI is separate child work under T-8.
## Scope-tag icon decision (review, 2026-06-10)
The per-field scope tag is an ICON, not a text label (space + cognitive load).
Chosen trio (location→global reach ladder), keeping the colour coding + a
tooltip with the word for clarity/a11y; tapping opens the scope menu:
- Project (this repo) = `folder` 0xe24a (teal) — already wired
- Always (all clide) = `globe` 0xe288 (amber) — add const
- Default (unset) = `circle-dashed` 0xe602 (grey) — add const
Alternatives considered: house (warmer home↔world pair), user-circle (mirrors
the real .clide vs ~/.clide files), gear (collides with "settings"). Note that
every Phosphor glyph already renders via PhosphorIconPainter(0xNNNN); only named
consts need adding. Preview/picking is blocked on a native glyph card (see the
new ticket) — goldens render the font as Ahem boxes.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-09 20:24:54', '2026-06-10 10:20:48', NULL, '267a1e8c9bab093fa443f7d8272525b9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB234WP4Y6Q16A0HFW8BSXMG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Native Phosphor glyph preview card (clide icon show)', 'A conversation-pane card that renders Phosphor glyphs by name/codepoint, so icons can be previewed and picked in the live pane.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 10:20:54', '2026-06-10 10:20:54', NULL, '645c474b9333b748ea355cb0d8191fd9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB234WP4Y6Q16A0HFW8BSXMG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Native Phosphor glyph preview card (clide icon show)', 'A conversation-pane card that renders Phosphor glyphs by name/codepoint, so icons can be previewed and picked in the live pane.
## Why
Picking icons (e.g. the settings scope tags, T-302) needs to SEE real glyphs.
Frame0 can''t render Phosphor (private-use codepoints, no font) and golden tests
render the font as Ahem boxes (only painter_bold_metrics loads a real font), so
preview only works where the app has the font — a native card is the vehicle.
## Deliverable
A conversation-pane card (peer of the image card, T-249/T-252) that renders a
grid of Phosphor glyphs with their name + codepoint, driven by the clide CLI —
e.g. `clide icon show <name|0xNNNN ...>` or `clide glyphs [filter]` (D-6 parity).
Uses the already-bundled Phosphor.ttf via PhosphorIconPainter.
## Notes / scope
- Every glyph already renders via `PhosphorIconPainter(0xNNNN)`; the 49 named
consts in lib/widgets/src/icons/phosphor.dart are curated sugar. We do NOT
need to bulk-add all ~1512 consts for availability.
- OPTIONAL behind this card: generate the full name→codepoint set (from
.claude/skills/ui-design/references/phosphor-glyphs.md, 1512 entries) so glyphs
are discoverable by name — but only if the card makes them browsable; weigh
against clide''s curated/minimal philosophy.
- The card is display-only (D-78); a click could copy the codepoint/name.
- Surfaced 2026-06-10 while choosing settings scope icons (T-302).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 10:20:54', '2026-06-10 10:21:09', NULL, '00bfb44c7719b117d224dfe778a5bffe', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7H8JWCP59WQVD0FW4', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'Frame0 wireframe: settings screen design', 'Design the app''s settings screen as a Frame0 wireframe before building `builtin.settings-ui` (see Tier 6 epic T-8). Output is a wireframe to align on layout/IA, not implementation.
**Deliverable**
- Frame0 wireframe authored via the frame0-wireframe skill: local JSON source-of-truth synced to Frame0, exported for review.
- Covers the settings screen shell + at least one fully-rendered category so the form-field patterns are concrete.
**Scope to frame (from T-8)**
- Schema-driven panel: form fields keyed off the schema each subsystem registers against the kernel SettingsStore; edits write back to `.clide/settings.yaml`.
- Navigation/IA: how categories are grouped and selected (sidebar list? sections? search?).
- Field types to mock: toggle, enum/select (e.g. keymap preset), text/number, and a ''opens external file'' affordance (e.g. editor `.editorconfig` per T-290).
- Known consumers to account for: keymap preset switching (T-115/T-64/T-65/T-66), editor settings (T-290), activity fold level (T-183), theme picker (Tier 6 theming UI).
**Open design questions for the wireframe to answer**
- Settings as a full-screen view, a pane/tab, or a modal?
- Per-project (`.clide/settings.yaml`) vs. user-global scope — shown together or switched?
- Search/filter across all settings.
- How schema-driven fields render labels, help text, defaults, and reset.
**Constraints**
- Follow clide visual language — pull theme tokens / control geometry from the ui-design skill so the wireframe maps cleanly to real widgets (no Material/Cupertino).
This is the design step; implementation of the actual settings UI is separate child work under T-8.
## Scope-tag icon decision (review, 2026-06-10)
The per-field scope tag is an ICON, not a text label (space + cognitive load).
Chosen trio (location→global reach ladder), keeping the colour coding + a
tooltip with the word for clarity/a11y; tapping opens the scope menu:
- Project (this repo) = `folder` 0xe24a (teal) — already wired
- Always (all clide) = `globe` 0xe288 (amber) — add const
- Default (unset) = `circle-dashed` 0xe602 (grey) — add const
Alternatives considered: house (warmer home↔world pair), user-circle (mirrors
the real .clide vs ~/.clide files), gear (collides with "settings"). Note that
every Phosphor glyph already renders via PhosphorIconPainter(0xNNNN); only named
consts need adding. Preview/picking is blocked on a native glyph card (see the
new ticket) — goldens render the font as Ahem boxes.', 'done', 'medium', NULL, NULL, NULL, '2026-06-09 20:24:54', '2026-06-10 10:21:48', NULL, '0ab1ccc1bfc22b98f08f6e0c58116f2e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB23Z52EVGQ4ZDAQ5BVAJ13R', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Phosphor icons: single label→codepoint map; reference glyphs by name, not codepoint', 'Replace the 49 hand-maintained named consts in lib/widgets/src/icons/phosphor.dart with ONE generated const Map<String,int> of all 1512 Phosphor glyphs (exact kebab-case upstream names → codepoint). Code references glyphs via PhosphorIcons.byName(''folder'') — no raw 0xNNNN in feature code; codepoints live only in the generated map. Migrate the ~32 call sites (by codepoint, not guessed names). Add a test that scans lib/ for byName(''...'') literals and asserts each exists in the map, recovering the compile-time typo safety the named consts gave. Map is regeneratable from .claude/skills/ui-design/references/phosphor-glyphs.md. Foundation for the glyph preview card (T-313).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 10:24:29', '2026-06-10 10:24:29', NULL, 'f47e0bdac48ba76f91043659a1d25a97', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB23Z52EVGQ4ZDAQ5BVAJ13R', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Phosphor icons: single label→codepoint map; reference glyphs by name, not codepoint', 'Replace the 49 hand-maintained named consts in lib/widgets/src/icons/phosphor.dart with ONE generated const Map<String,int> of all 1512 Phosphor glyphs (exact kebab-case upstream names → codepoint). Code references glyphs via PhosphorIcons.byName(''folder'') — no raw 0xNNNN in feature code; codepoints live only in the generated map. Migrate the ~32 call sites (by codepoint, not guessed names). Add a test that scans lib/ for byName(''...'') literals and asserts each exists in the map, recovering the compile-time typo safety the named consts gave. Map is regeneratable from .claude/skills/ui-design/references/phosphor-glyphs.md. Foundation for the glyph preview card (T-313).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 10:24:29', '2026-06-10 10:29:08', NULL, '6616d70e7c621b0f204336ed29d40bf0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB234WP4Y6Q16A0HFW8BSXMG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Native Phosphor glyph preview card (clide icon show)', 'A conversation-pane card that renders Phosphor glyphs by name/codepoint, so icons can be previewed and picked in the live pane.
## Why
Picking icons (e.g. the settings scope tags, T-302) needs to SEE real glyphs.
Frame0 can''t render Phosphor (private-use codepoints, no font) and golden tests
render the font as Ahem boxes (only painter_bold_metrics loads a real font), so
preview only works where the app has the font — a native card is the vehicle.
## Deliverable
A conversation-pane card (peer of the image card, T-249/T-252) that renders a
grid of Phosphor glyphs with their name + codepoint, driven by the clide CLI —
e.g. `clide icon show <name|0xNNNN ...>` or `clide glyphs [filter]` (D-6 parity).
Uses the already-bundled Phosphor.ttf via PhosphorIconPainter.
## Notes / scope
- Every glyph already renders via `PhosphorIconPainter(0xNNNN)`; the 49 named
consts in lib/widgets/src/icons/phosphor.dart are curated sugar. We do NOT
need to bulk-add all ~1512 consts for availability.
- OPTIONAL behind this card: generate the full name→codepoint set (from
.claude/skills/ui-design/references/phosphor-glyphs.md, 1512 entries) so glyphs
are discoverable by name — but only if the card makes them browsable; weigh
against clide''s curated/minimal philosophy.
- The card is display-only (D-78); a click could copy the codepoint/name.
- Surfaced 2026-06-10 while choosing settings scope icons (T-302).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 10:20:54', '2026-06-10 10:38:06', NULL, '3852ea0ab0ba8510fd4fbf1fa6d20239', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB234WP4Y6Q16A0HFW8BSXMG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Native Phosphor glyph card — multi-icon list with optional labels/descriptions (previews + choice offers)', 'A conversation-pane card that renders one OR MANY Phosphor glyphs by name/codepoint, each with an optional label and an optional description line, so icons can be previewed and compared in the live pane — and so a set of candidate icons can be offered as a labelled choice list (e.g. picking settings-scope icons, T-302).
## Why
Picking icons needs to SEE real glyphs side by side with what each one means. Frame0 can''t render Phosphor (private-use codepoints, no font) and goldens render the font as Ahem boxes, so preview only works where the app has the font — a native card is the vehicle. Beyond a bare grid, real icon decisions are ''which of these N icons, for these N meanings'' — so each entry wants an optional label (what we''d call it) and an optional description (what it represents), turning the card into an offer/choice list.
## Deliverable
A conversation-pane card (peer of the image card, T-249/T-252) that renders a list/grid of entries, each entry = glyph + optional label + optional description, driven by the clide CLI (D-6 parity). Uses the bundled Phosphor.ttf via PhosphorIconPainter.
### Multi-size rendering (per entry)
Each icon is shown at SEVERAL sizes, not one: (1) a large hero rendering so the glyph''s detail is clearly legible, and (2) a sample at each font-size token the app actually uses inline, so you can judge how the glyph reads at real UI sizes. The relevant scale is the inline type scale in lib/widgets/src/typography.dart: clideFontBadge (11), clideFontSmall (12), clideFontMeta (13), clideFontCaption/clideFontMono (14), clideFontBody (15). Drive sizing off those tokens (not bare numbers) so the row tracks the scale if it changes; the hero size can be a named large constant. Lay the size samples out in a single row/strip per entry, smallest to largest, labelled with the token/px so a reviewer sees exactly where each size lands.
### CLI shape
- Trivial case stays terse, mirroring image show: `clide icon show <name|0xNNNN> [<name> ...]` renders the bare glyph(s) with name + codepoint.
- Labelled/described case takes a structured payload (per entry has up to three fields, so flags get unwieldy): a JSON array via `--stdin`/`--file`, each item `{"icon": "gear", "label": "Settings", "description": "global scope"}` where label and description are optional. icon resolves by kebab-case name (PhosphorIcons.byName) or 0xNNNN codepoint.
- One card per invocation; the entries render as rows (or a grid when label/description are absent).
## Display-only vs. selectable (D-78 — key boundary)
The card itself is a CONVERSATION WIDGET and stays display-only (D-78): it shows the labelled icon options and a click may copy the codepoint/name, but it does NOT resolve a selection inline. When the offer needs to be an actual PICK that returns a choice, that selection belongs in the interaction zone (AskUserQuestion-style option list that replaces the composer), NOT inline in the transcript — see the interaction-zone rule. So scope this ticket to the display/offer card; an interaction-zone icon picker (or feeding these entries into an AskUserQuestion option list) is a follow-up, not part of this card. Flag which path we want before building the selectable variant.
## Notes / scope
- Name->codepoint resolution ALREADY EXISTS: lib/widgets/src/icons/phosphor_glyphs.g.dart (generated, 1512 glyphs) + PhosphorIcons.byName. The earlier ''OPTIONAL: generate the full set'' caveat is resolved — every named glyph is already resolvable; the 49 curated consts in phosphor.dart remain curated sugar.
- Every glyph already renders via PhosphorIconPainter(0xNNNN).
- label and description are both optional per entry; an entry with neither degrades to the bare-preview look.
- Surfaced 2026-06-10 while choosing settings scope icons (T-302); refined 2026-06-10 to cover multi-icon labelled offer/choice lists.
## Acceptance
- `clide icon show` accepts multiple icons in one call and renders them in a single conversation card.
- A structured (JSON) input lets each icon carry an optional label and optional description, both rendered alongside the glyph.
- Each icon renders at multiple sizes: a large hero plus one sample at each inline font-size token (badge 11 -> body 15), sized off the typography tokens and labelled so the reviewer sees legibility at real UI sizes.
- The card is display-only (no inline selection); the description records that a selectable picker routes through the interaction zone as a separate follow-up.
- Unknown/invalid icon names fail with a clear user error, not a blank glyph.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 10:20:54', '2026-06-10 10:41:56', NULL, '6b462c12d7c4b839c4b5affd6e1546b1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB234WP4Y6Q16A0HFW8BSXMG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Native Phosphor glyph card — multi-icon list with optional labels/descriptions (previews + choice offers)', 'A conversation-pane card that renders one OR MANY Phosphor glyphs by name/codepoint, each with an optional label and an optional description line, so icons can be previewed and compared in the live pane — and so a set of candidate icons can be offered as a labelled choice list (e.g. picking settings-scope icons, T-302).
## Why
Picking icons needs to SEE real glyphs side by side with what each one means. Frame0 can''t render Phosphor (private-use codepoints, no font) and goldens render the font as Ahem boxes, so preview only works where the app has the font — a native card is the vehicle. Beyond a bare grid, real icon decisions are ''which of these N icons, for these N meanings'' — so each entry wants an optional label (what we''d call it) and an optional description (what it represents), turning the card into an offer/choice list.
## Deliverable
A conversation-pane card (peer of the image card, T-249/T-252) that renders a list/grid of entries, each entry = glyph + optional label + optional description, driven by the clide CLI (D-6 parity). Uses the bundled Phosphor.ttf via PhosphorIconPainter.
### Multi-size rendering (per entry)
Each icon is shown at SEVERAL sizes, not one: (1) a large hero rendering at 48px so the glyph''s detail is clearly legible, and (2) a sample at each font-size token the app actually uses inline, so you can judge how the glyph reads at real UI sizes. The relevant inline scale is in lib/widgets/src/typography.dart: clideFontBadge (11), clideFontSmall (12), clideFontMeta (13), clideFontCaption/clideFontMono (14), clideFontBody (15). Drive the inline samples off those tokens (not bare numbers) so the row tracks the scale if it changes. The 48px hero is larger than any existing type token (welcome banner is 52, dialog title 16), so add a named preview constant for it (e.g. clideIconPreviewHero = 48) rather than a bare 48 — keep the scale coherent. Lay the size samples out in a single row/strip per entry, smallest to largest, labelled with the token/px so a reviewer sees exactly where each size lands.
### CLI shape
- Trivial case stays terse, mirroring image show: `clide icon show <name|0xNNNN> [<name> ...]` renders the bare glyph(s) with name + codepoint.
- Labelled/described case takes a structured payload (per entry has up to three fields, so flags get unwieldy): a JSON array via `--stdin`/`--file`, each item `{"icon": "gear", "label": "Settings", "description": "global scope"}` where label and description are optional. icon resolves by kebab-case name (PhosphorIcons.byName) or 0xNNNN codepoint.
- One card per invocation; the entries render as rows (or a grid when label/description are absent).
## Display-only card + interaction-zone selection (D-78 — decided)
DECIDED: the display card is display-only; the SELECTION happens in the convo box (interaction zone), not on the card. The card renders the labelled icon options for the user to SEE; when a pick is needed, Claude offers a matching choice list in the interaction zone (AskUserQuestion-style options that replace the composer), and the user selects there. This keeps conversation widgets display-only per D-78 and the interaction-zone rule.
The LABEL is the bridge between the two surfaces: Claude attaches a label to each icon on the display card, then offers the SAME labels as the options in the interaction-zone choice list — so ''I pick Settings'' in the convo box maps unambiguously back to the glyph the user saw on the card. That''s why per-entry labels are first-class here: they exist to facilitate this show-then-pick flow, with the description giving the extra context that doesn''t fit a one-word option. The card may still copy a codepoint/name on click (a convenience), but it never resolves the choice itself.
## Notes / scope
- Name->codepoint resolution ALREADY EXISTS: lib/widgets/src/icons/phosphor_glyphs.g.dart (generated, 1512 glyphs) + PhosphorIcons.byName. The earlier ''OPTIONAL: generate the full set'' caveat is resolved — every named glyph is already resolvable; the 49 curated consts in phosphor.dart remain curated sugar.
- Every glyph already renders via PhosphorIconPainter(0xNNNN).
- label and description are both optional per entry; an entry with neither degrades to the bare-preview look.
- Surfaced 2026-06-10 while choosing settings scope icons (T-302); refined 2026-06-10 to cover multi-icon labelled offer/choice lists.
## Acceptance
- `clide icon show` accepts multiple icons in one call and renders them in a single conversation card.
- A structured (JSON) input lets each icon carry an optional label and optional description, both rendered alongside the glyph.
- Each icon renders at multiple sizes: a 48px hero (new named constant, e.g. clideIconPreviewHero) plus one sample at each inline font-size token (badge 11 -> body 15), sized off the typography tokens and labelled so the reviewer sees legibility at real UI sizes.
- The card is display-only (no inline selection); selection happens in the interaction zone (convo box) via a Claude-offered choice list whose options reuse the per-icon labels from the card.
- Unknown/invalid icon names fail with a clear user error, not a blank glyph.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 10:20:54', '2026-06-10 10:43:01', NULL, '04e889f18335733517fba43971eb67f4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB234WP4Y6Q16A0HFW8BSXMG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Native Phosphor glyph card — multi-icon list with optional labels/descriptions (previews + choice offers)', 'A conversation-pane card that renders one OR MANY Phosphor glyphs by name/codepoint, each with an optional label and an optional description line, so icons can be previewed and compared in the live pane — and so a set of candidate icons can be offered as a labelled choice list (e.g. picking settings-scope icons, T-302).
## Why
Picking icons needs to SEE real glyphs side by side with what each one means. Frame0 can''t render Phosphor (private-use codepoints, no font) and goldens render the font as Ahem boxes, so preview only works where the app has the font — a native card is the vehicle. Beyond a bare grid, real icon decisions are ''which of these N icons, for these N meanings'' — so each entry wants an optional label (what we''d call it) and an optional description (what it represents), turning the card into an offer/choice list.
## Deliverable
A conversation-pane card (peer of the image card, T-249/T-252) that renders a list/grid of entries, each entry = glyph + optional label + optional description, driven by the clide CLI (D-6 parity). Uses the bundled Phosphor.ttf via PhosphorIconPainter.
### Multi-size rendering (per entry)
Each icon is shown at SEVERAL sizes, not one: (1) a large hero rendering so the glyph''s detail is clearly legible, and (2) a sample at each font-size token the app actually uses inline, so you can judge how the glyph reads at real UI sizes. The relevant inline scale is in lib/widgets/src/typography.dart: clideFontBadge (11), clideFontSmall (12), clideFontMeta (13), clideFontCaption/clideFontMono (14), clideFontBody (15). Drive the inline samples off those tokens (not bare numbers) so the row tracks the scale if it changes. For the hero, REUSE the existing clideFontWelcomeBanner (52) token rather than adding a new constant — it''s already the app''s named oversized size; no new token needed. Lay the size samples out in a single row/strip per entry, smallest to largest, labelled with the token/px so a reviewer sees exactly where each size lands.
### CLI shape
- Trivial case stays terse, mirroring image show: `clide icon show <name|0xNNNN> [<name> ...]` renders the bare glyph(s) with name + codepoint.
- Labelled/described case takes a structured payload (per entry has up to three fields, so flags get unwieldy): a JSON array via `--stdin`/`--file`, each item `{"icon": "gear", "label": "Settings", "description": "global scope"}` where label and description are optional. icon resolves by kebab-case name (PhosphorIcons.byName) or 0xNNNN codepoint.
- One card per invocation; the entries render as rows (or a grid when label/description are absent).
## Display-only card + interaction-zone selection (D-78 — decided)
DECIDED: the display card is display-only; the SELECTION happens in the convo box (interaction zone), not on the card. The card renders the labelled icon options for the user to SEE; when a pick is needed, Claude offers a matching choice list in the interaction zone (AskUserQuestion-style options that replace the composer), and the user selects there. This keeps conversation widgets display-only per D-78 and the interaction-zone rule.
The LABEL is the bridge between the two surfaces: Claude attaches a label to each icon on the display card, then offers the SAME labels as the options in the interaction-zone choice list — so ''I pick Settings'' in the convo box maps unambiguously back to the glyph the user saw on the card. That''s why per-entry labels are first-class here: they exist to facilitate this show-then-pick flow, with the description giving the extra context that doesn''t fit a one-word option. The card may still copy a codepoint/name on click (a convenience), but it never resolves the choice itself.
## Notes / scope
- Name->codepoint resolution ALREADY EXISTS: lib/widgets/src/icons/phosphor_glyphs.g.dart (generated, 1512 glyphs) + PhosphorIcons.byName. The earlier ''OPTIONAL: generate the full set'' caveat is resolved — every named glyph is already resolvable; the 49 curated consts in phosphor.dart remain curated sugar.
- Every glyph already renders via PhosphorIconPainter(0xNNNN).
- label and description are both optional per entry; an entry with neither degrades to the bare-preview look.
- Surfaced 2026-06-10 while choosing settings scope icons (T-302); refined 2026-06-10 to cover multi-icon labelled offer/choice lists.
## Acceptance
- `clide icon show` accepts multiple icons in one call and renders them in a single conversation card.
- A structured (JSON) input lets each icon carry an optional label and optional description, both rendered alongside the glyph.
- Each icon renders at multiple sizes: a hero at the existing clideFontWelcomeBanner (52) token plus one sample at each inline font-size token (badge 11 -> body 15), sized off the typography tokens and labelled so the reviewer sees legibility at real UI sizes.
- The card is display-only (no inline selection); selection happens in the interaction zone (convo box) via a Claude-offered choice list whose options reuse the per-icon labels from the card.
- Unknown/invalid icon names fail with a clear user error, not a blank glyph.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 10:20:54', '2026-06-10 10:44:06', NULL, '9a25fe730b81f5192fb4142d66fbff85', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB23Z52EVGQ4ZDAQ5BVAJ13R', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Phosphor icons: single label→codepoint map; reference glyphs by name, not codepoint', 'Replace the 49 hand-maintained named consts in lib/widgets/src/icons/phosphor.dart with ONE generated const Map<String,int> of all 1512 Phosphor glyphs (exact kebab-case upstream names → codepoint). Code references glyphs via PhosphorIcons.byName(''folder'') — no raw 0xNNNN in feature code; codepoints live only in the generated map. Migrate the ~32 call sites (by codepoint, not guessed names). Add a test that scans lib/ for byName(''...'') literals and asserts each exists in the map, recovering the compile-time typo safety the named consts gave. Map is regeneratable from .claude/skills/ui-design/references/phosphor-glyphs.md. Foundation for the glyph preview card (T-313).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 10:24:29', '2026-06-10 10:46:25', NULL, 'aa95db9de3811c6058836e661dd304e8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB234WP4Y6Q16A0HFW8BSXMG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Native Phosphor glyph card — multi-icon list with optional labels/descriptions (previews + choice offers)', 'A conversation-pane card that renders one OR MANY Phosphor glyphs by name/codepoint, each with an optional label and an optional description line, so icons can be previewed and compared in the live pane — and so a set of candidate icons can be offered as a labelled choice list (e.g. picking settings-scope icons, T-302).
## Why
Picking icons needs to SEE real glyphs side by side with what each one means. Frame0 can''t render Phosphor (private-use codepoints, no font) and goldens render the font as Ahem boxes, so preview only works where the app has the font — a native card is the vehicle. Beyond a bare grid, real icon decisions are ''which of these N icons, for these N meanings'' — so each entry wants an optional label (what we''d call it) and an optional description (what it represents), turning the card into an offer/choice list.
## Deliverable
A conversation-pane card (peer of the image card, T-249/T-252) that renders a list/grid of entries, each entry = glyph + optional label + optional description, driven by the clide CLI (D-6 parity). Uses the bundled Phosphor.ttf via PhosphorIconPainter.
### Multi-size rendering (per entry)
Each icon is shown at SEVERAL sizes, not one: (1) a large hero rendering so the glyph''s detail is clearly legible, and (2) a sample at each font-size token the app actually uses inline, so you can judge how the glyph reads at real UI sizes. The relevant inline scale is in lib/widgets/src/typography.dart: clideFontBadge (11), clideFontSmall (12), clideFontMeta (13), clideFontCaption/clideFontMono (14), clideFontBody (15). Drive the inline samples off those tokens (not bare numbers) so the row tracks the scale if it changes. For the hero, REUSE the existing clideFontWelcomeBanner (52) token rather than adding a new constant — it''s already the app''s named oversized size; no new token needed. Lay the size samples out in a single row/strip per entry, smallest to largest, labelled with the token/px so a reviewer sees exactly where each size lands.
### CLI shape + IPC wiring (mirror image.show exactly)
Follow the image-card template (lib/src/daemon/image_commands.dart) end to end — it is the proven D-6 parity pattern:
- REGISTRATION: a dotted `icon.show` command on DaemonDispatcher (invoked as `clide icon show`), declared with a CommandSchema — positional + per-arg ArgSpec — exactly like image.show''s `{positional: [''path''], args: {...}}`. Handler stays Flutter-free so it runs under `dart test`.
- BARE PREVIEW (variadic): one or more icons as positionals via ArgType.stringList — `clide icon show gear folder gauge` — each a kebab-case name (resolved by PhosphorIcons.byName) or a 0xNNNN codepoint. stringList is already supported by the schema (lib/src/ipc/command_schema.dart) and the argv parser, so no new CLI plumbing.
- LABELLED/DESCRIBED entries: a `--file <path.json>` flag whose value is a JSON array of `{"icon": "gear", "label": "Settings", "description": "global scope"}` (label, description optional); the handler reads and parses the file. NOTE: do NOT spec `--stdin` — clide''s CLI argv parser (lib/src/cli/argv_to_request.dart) only produces positionals/flags/passthrough and has no stdin path, so a `--file` flag (or repeated flags) is the grounded choice unless we deliberately add stdin support as separate work.
- RENDER PATH: validate + resolve icon names in the handler (inject a resolver the way image.show injects ImagePathResolver, so headless/dart-test stays filesystem-free), then publish on a dedicated MessageBus channel — e.g. `iconShowChannel = ''icon''`, peer of `imageShowChannel = ''image''` — captured post-boot in main.dart; the Claude extension subscribes to that literal and injects the card into the primary session''s conversation log. Honest failure (IpcError userError/notFound) on an unknown glyph name or a malformed/missing --file, and on no live UI bus (headless), mirroring image.show.
- One card per invocation; entries render as rows (or a grid when label/description are absent).
## Display-only card + interaction-zone selection (D-78 — decided)
DECIDED: the display card is display-only; the SELECTION happens in the convo box (interaction zone), not on the card. The card renders the labelled icon options for the user to SEE; when a pick is needed, Claude offers a matching choice list in the interaction zone (AskUserQuestion-style options that replace the composer), and the user selects there. This keeps conversation widgets display-only per D-78 and the interaction-zone rule.
The LABEL is the bridge between the two surfaces: Claude attaches a label to each icon on the display card, then offers the SAME labels as the options in the interaction-zone choice list — so ''I pick Settings'' in the convo box maps unambiguously back to the glyph the user saw on the card. That''s why per-entry labels are first-class here: they exist to facilitate this show-then-pick flow, with the description giving the extra context that doesn''t fit a one-word option. The card may still copy a codepoint/name on click (a convenience), but it never resolves the choice itself.
## Notes / scope
- Name->codepoint resolution ALREADY EXISTS: lib/widgets/src/icons/phosphor_glyphs.g.dart (generated, 1512 glyphs) + PhosphorIcons.byName. The earlier ''OPTIONAL: generate the full set'' caveat is resolved — every named glyph is already resolvable; the 49 curated consts in phosphor.dart remain curated sugar.
- Every glyph already renders via PhosphorIconPainter(0xNNNN).
- label and description are both optional per entry; an entry with neither degrades to the bare-preview look.
- Surfaced 2026-06-10 while choosing settings scope icons (T-302); refined 2026-06-10 to cover multi-icon labelled offer/choice lists.
## Acceptance
- `icon.show` is registered on DaemonDispatcher with a CommandSchema and invoked as `clide icon show`, mirroring image.show; the handler is Flutter-free and publishes on an `icon` MessageBus channel injected by the Claude extension.
- `clide icon show <name> [<name> ...]` accepts multiple icons (variadic stringList positionals) in one call and renders them in a single conversation card.
- A `--file <path.json>` payload lets each icon carry an optional label and optional description, both rendered alongside the glyph (no --stdin — not supported by the CLI parser).
- Each icon renders at multiple sizes: a hero at the existing clideFontWelcomeBanner (52) token plus one sample at each inline font-size token (badge 11 -> body 15), sized off the typography tokens and labelled so the reviewer sees legibility at real UI sizes.
- The card is display-only (no inline selection); selection happens in the interaction zone (convo box) via a Claude-offered choice list whose options reuse the per-icon labels from the card.
- Unknown/invalid icon names fail with a clear user error, not a blank glyph.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 10:20:54', '2026-06-10 10:49:59', NULL, 'a2281b4ce92c45a7783d48bc8b74b872', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ACSDBDZARV3NNGYD9NYYR', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'clide CLI: accept a JSON payload on stdin for structured commands', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 10:52:34', '2026-06-10 10:52:34', NULL, '12a5619d2d8d756bc4b703094a28fd69', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2AD3HPR3HXSVASVZEX8PK0', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'clide image show: JSON metadata payload so images can be annotated (label/description/markers)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 10:52:36', '2026-06-10 10:52:36', NULL, '7f9a9723a231cfda8e8b05961487b50d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ACSDBDZARV3NNGYD9NYYR', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'clide CLI: accept a JSON payload on stdin for structured commands', 'Add a stdin path to clide''s CLI so a command can receive a JSON payload piped in — `… | clide icon show --stdin`, `cat meta.json | clide image show foo.png --stdin` — instead of only positionals/flags or a `--file`.
## Why
Structured commands (the labelled icon-card entries in T-313, image annotation metadata in T-316) want a JSON payload that''s awkward to express as flags. Today clide''s CLI argv parser (lib/src/cli/argv_to_request.dart) only produces positionals, --flags, and `-- passthrough`; there is no stdin path. T-313 therefore falls back to a `--file <path.json>` flag. A `--stdin` convention is the ergonomic peer of `--file` for piping, and is shared infra both icon.show and image.show consume.
## Where the work lives
clide''s IPC server runs in-process and the `clide` CLI is a thin client that serialises argv into an IpcRequest over CLIDE_SOCK. So stdin must be slurped CLIENT-SIDE (in lib/src/cli/, around argv_to_request.dart / argv_dispatch.dart) and folded into the request before it is sent — the in-process handler never sees the real stdin. Decide how it surfaces in the envelope: e.g. a reserved `stdin`/`payload` field on IpcRequest, or a synthesised arg the CommandSchema can opt into (an ArgSpec flag like `acceptsStdin`, mirroring how ArgType.stringList is declared in lib/src/ipc/command_schema.dart).
## Scope / decisions
- Generic infra, not icon/image specific — once landed, any command opts in via its CommandSchema.
- Keep `--file` working; --stdin and --file should be mutually exclusive (error if both given) or layered with a defined precedence.
- Text/JSON payloads only to start; define a size cap and a clear error when --stdin is passed but stdin is empty/not a pipe (don''t hang waiting on a TTY).
- Honest IpcError (userError) on malformed JSON, surfaced like image.show''s other validation failures.
- D-6 parity: document the stdin convention alongside the other CLI verbs.
## Acceptance
- A command can declare (via CommandSchema) that it accepts a stdin payload; piping JSON in populates the IpcRequest with that payload.
- `clide icon show --stdin` (T-313) and `clide image show <path> --stdin` (T-316) both consume it.
- --stdin + --file together is a clear user error; --stdin with no piped input fails fast, never hangs on a TTY.
- Malformed JSON returns a userError with a helpful message.
Unblocks the piped-JSON variants of T-313 (icon entries) and T-316 (image annotations); both can also ship with --file independently of this.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 10:52:34', '2026-06-10 10:55:30', NULL, '918b4d6d1b3e3e45c7431196419cf40d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2AD3HPR3HXSVASVZEX8PK0', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'clide image show: JSON metadata payload so images can be annotated (label/description/markers)', 'Give `clide image show` the same structured-metadata plumbing the icon card gets (T-313), so an image driven into the conversation can carry ANNOTATIONS — not just a single one-line caption.
## Why
Today image.show (lib/src/daemon/image_commands.dart) takes a `path` positional plus an optional `--caption` string, and ImageMessage (lib/builtin/claude/src/transcript_reader.dart:160) holds only `path` + optional one-line `caption`. When showing a screenshot/wireframe to discuss, one line is thin — we want to attach a title/label, a longer description, and potentially captioned markers (callouts on specific spots). This mirrors the per-entry label+description the icon card introduces.
## Deliverable
Extend image.show to accept a JSON metadata payload (via the new stdin plumbing T-315 and/or a `--file <path.json>` flag, same as T-313''s icon entries) describing the image''s annotations, e.g.:
{ "path": "docs/shot.png", "label": "HUD v3", "description": "note the cramped status row", "caption": "before the fix" }
Extend ImageMessage + the image card to render the richer metadata. Keep the existing `clide image show <path> --caption "…"` form working unchanged (back-compat); the JSON payload is additive.
## Open decision — text vs visual annotation
''Annotated'' could mean (a) richer TEXT metadata shown around the image (label + description + caption), or (b) VISUAL overlays drawn ON the image (markers/arrows/numbered callouts at coordinates). Start with (a) — straightforward extension of the current card. (b) is a bigger, clide-owned CustomPaint job (markers:[{x,y,label}] painted over the image); feasible since the image card is our own rendering, but scope it as a follow-up unless we decide we need it now. Flag which we want before building markers.
## Dependencies
- Pairs with T-315 (stdin JSON plumbing) for the piped form; can land with `--file` alone if T-315 isn''t ready.
- Parallel to T-313 (icon card) — same --file/--stdin metadata pattern, same Flutter-free handler + MessageBus publish path.
## Acceptance
- `clide image show` accepts a JSON metadata payload (via --file, and via --stdin once T-315 lands) carrying at least label + description alongside the existing caption.
- ImageMessage + the image card render the added metadata.
- The existing `image show <path> [--caption]` form is unchanged.
- Malformed payload / unknown fields fail with a clear userError, mirroring image.show''s current validation.
- Visual marker overlays are explicitly noted as a separate follow-up unless pulled in by decision.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 10:52:36', '2026-06-10 10:55:34', NULL, '6f46462a2482a842fe606d13da6905cd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB234WP4Y6Q16A0HFW8BSXMG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Native Phosphor glyph card — multi-icon list with optional labels/descriptions (previews + choice offers)', 'A conversation-pane card that renders one OR MANY Phosphor glyphs by name/codepoint, each with an optional label and an optional description line, so icons can be previewed and compared in the live pane — and so a set of candidate icons can be offered as a labelled choice list (e.g. picking settings-scope icons, T-302).
## Why
Picking icons needs to SEE real glyphs side by side with what each one means. Frame0 can''t render Phosphor (private-use codepoints, no font) and goldens render the font as Ahem boxes, so preview only works where the app has the font — a native card is the vehicle. Beyond a bare grid, real icon decisions are ''which of these N icons, for these N meanings'' — so each entry wants an optional label (what we''d call it) and an optional description (what it represents), turning the card into an offer/choice list.
## Deliverable
A conversation-pane card (peer of the image card, T-249/T-252) that renders a list/grid of entries, each entry = glyph + optional label + optional description, driven by the clide CLI (D-6 parity). Uses the bundled Phosphor.ttf via PhosphorIconPainter.
### Multi-size rendering (per entry)
Each icon is shown at SEVERAL sizes, not one: (1) a large hero rendering so the glyph''s detail is clearly legible, and (2) a sample at each font-size token the app actually uses inline, so you can judge how the glyph reads at real UI sizes. The relevant inline scale is in lib/widgets/src/typography.dart: clideFontBadge (11), clideFontSmall (12), clideFontMeta (13), clideFontCaption/clideFontMono (14), clideFontBody (15). Drive the inline samples off those tokens (not bare numbers) so the row tracks the scale if it changes. For the hero, REUSE the existing clideFontWelcomeBanner (52) token rather than adding a new constant — it''s already the app''s named oversized size; no new token needed. Lay the size samples out in a single row/strip per entry, smallest to largest, labelled with the token/px so a reviewer sees exactly where each size lands.
### CLI shape + IPC wiring (mirror image.show exactly)
Follow the image-card template (lib/src/daemon/image_commands.dart) end to end — it is the proven D-6 parity pattern:
- REGISTRATION: a dotted `icon.show` command on DaemonDispatcher (invoked as `clide icon show`), declared with a CommandSchema — positional + per-arg ArgSpec — exactly like image.show''s `{positional: [''path''], args: {...}}`. Handler stays Flutter-free so it runs under `dart test`.
- BARE PREVIEW (variadic): one or more icons as positionals via ArgType.stringList — `clide icon show gear folder gauge` — each a kebab-case name (resolved by PhosphorIcons.byName) or a 0xNNNN codepoint. stringList is already supported by the schema (lib/src/ipc/command_schema.dart) and the argv parser, so no new CLI plumbing.
- LABELLED/DESCRIBED entries: a `--file <path.json>` flag whose value is a JSON array of `{"icon": "gear", "label": "Settings", "description": "global scope"}` (label, description optional); the handler reads and parses the file. NOTE: do NOT spec `--stdin` — clide''s CLI argv parser (lib/src/cli/argv_to_request.dart) only produces positionals/flags/passthrough and has no stdin path, so a `--file` flag (or repeated flags) is the grounded choice unless we deliberately add stdin support as separate work.
- RENDER PATH: validate + resolve icon names in the handler (inject a resolver the way image.show injects ImagePathResolver, so headless/dart-test stays filesystem-free), then publish on a dedicated MessageBus channel — e.g. `iconShowChannel = ''icon''`, peer of `imageShowChannel = ''image''` — captured post-boot in main.dart; the Claude extension subscribes to that literal and injects the card into the primary session''s conversation log. Honest failure (IpcError userError/notFound) on an unknown glyph name or a malformed/missing --file, and on no live UI bus (headless), mirroring image.show.
- One card per invocation; entries render as rows (or a grid when label/description are absent).
## Display-only card + interaction-zone selection (D-78 — decided)
DECIDED: the display card is display-only; the SELECTION happens in the convo box (interaction zone), not on the card. The card renders the labelled icon options for the user to SEE; when a pick is needed, Claude offers a matching choice list in the interaction zone (AskUserQuestion-style options that replace the composer), and the user selects there. This keeps conversation widgets display-only per D-78 and the interaction-zone rule.
The LABEL is the bridge between the two surfaces: Claude attaches a label to each icon on the display card, then offers the SAME labels as the options in the interaction-zone choice list — so ''I pick Settings'' in the convo box maps unambiguously back to the glyph the user saw on the card. That''s why per-entry labels are first-class here: they exist to facilitate this show-then-pick flow, with the description giving the extra context that doesn''t fit a one-word option. The card may still copy a codepoint/name on click (a convenience), but it never resolves the choice itself.
## Notes / scope
- Name->codepoint resolution ALREADY EXISTS: lib/widgets/src/icons/phosphor_glyphs.g.dart (generated, 1512 glyphs) + PhosphorIcons.byName. The earlier ''OPTIONAL: generate the full set'' caveat is resolved — every named glyph is already resolvable; the 49 curated consts in phosphor.dart remain curated sugar.
- Every glyph already renders via PhosphorIconPainter(0xNNNN).
- label and description are both optional per entry; an entry with neither degrades to the bare-preview look.
- Surfaced 2026-06-10 while choosing settings scope icons (T-302); refined 2026-06-10 to cover multi-icon labelled offer/choice lists.
## Acceptance
- `icon.show` is registered on DaemonDispatcher with a CommandSchema and invoked as `clide icon show`, mirroring image.show; the handler is Flutter-free and publishes on an `icon` MessageBus channel injected by the Claude extension.
- `clide icon show <name> [<name> ...]` accepts multiple icons (variadic stringList positionals) in one call and renders them in a single conversation card.
- A `--file <path.json>` payload lets each icon carry an optional label and optional description, both rendered alongside the glyph (no --stdin — not supported by the CLI parser).
- Each icon renders at multiple sizes: a hero at the existing clideFontWelcomeBanner (52) token plus one sample at each inline font-size token (badge 11 -> body 15), sized off the typography tokens and labelled so the reviewer sees legibility at real UI sizes.
- The card is display-only (no inline selection); selection happens in the interaction zone (convo box) via a Claude-offered choice list whose options reuse the per-icon labels from the card.
- Unknown/invalid icon names fail with a clear user error, not a blank glyph.
FOLLOW-UPS: the piped-JSON (--stdin) variant is split out as T-315 (generic CLI stdin plumbing); image.show gets the same metadata/annotation treatment in T-316. T-313 ships with --file regardless of T-315.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 10:20:54', '2026-06-10 10:55:49', NULL, 'e2a3afff7620d41cf75d0b55c90543df', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2EDCBYRBDSV9V1PJ1KE3CM', 'epic', NULL, 'Unified conversation drawing card (canvas renderer)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 11:10:07', '2026-06-10 11:10:07', NULL, 'f62f2ff781a057083ca0dc6855824778', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2EDCBYRBDSV9V1PJ1KE3CM', 'epic', NULL, 'Unified conversation drawing card (canvas renderer)', 'Epic for the unified conversation drawing card — ONE clide-owned canvas renderer driven by a JSON document, replacing the trend of one-off conversation renderers (image card, icon card, …). See decision D-91.
## The model
Think HTML `<canvas>`, NOT Obsidian''s `.canvas` (Obsidian naming its feature ''canvas'' doesn''t make its schema our pattern). The card''s JSON can express raw PRIMITIVES (rects/lines/text at coordinates — ''draw a box at x,y, a line from x,y to x,y'' works), but in the common case it runs in TEMPLATE mode: the JSON names a predefined component and the card grabs it. Templates lower onto the same primitive scene the raw API accepts (hybrid: scene-graph core + high-level block sugar).
Per drawn object there is an optional label + description widget rendered BENEATH it, shown only when those fields are present in the JSON.
The card is DISPLAY-ONLY (D-78) — selection/interaction happens in the interaction zone, never on the card. The labels are the bridge to interaction-zone choice lists (see T-313).
## Template set (expand as we go)
- image — display the file, lightbox on click
- icon — the multi-size glyph set (T-313)
- compare-images / before-after — two (or more) paths side by side, each with its label/description
- svg — render an SVG file
- graph — render a graph
- …more added incrementally; ''we will expand the templates as we go''
## Reuse goal
The same renderer is intended for reuse as the `.canvas` VIEWER elsewhere: a `.canvas` file is converted into this JSON (not the card adopting Obsidian''s schema natively). Narrows Q-4.
## Input
Driven via the clide CLI (D-6 parity), consuming the JSON-payload plumbing — `--file` and the stdin path (T-315).
## Children / related
- Core canvas engine (primitives + template dispatch + per-object label/description) — foundational.
- Per-template tickets: image, icon (T-313), compare/before-after, svg, graph.
- T-315 — stdin/--file JSON input plumbing (input channel).
- T-316 — image annotation metadata (folds in as the image/compare template''s label/description/markers).
- `.canvas` -> JSON converter / reuse (deferred).
- Existing image card (T-249/T-252): LEFT AS-IS for now; migrated onto the canvas once the canvas layer lands.
## Scope note
This is a real renderer with a schema, not a one-off widget. The schema must stay coherent across the primitive and template layers as templates accrue (D-91 cost). Start with a true canvas + the first templates; grow the template set over time.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 11:10:07', '2026-06-10 11:10:59', NULL, 'bc20218bb8e27a91cd16929d5b99352d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB234WP4Y6Q16A0HFW8BSXMG', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Native Phosphor glyph card — multi-icon list with optional labels/descriptions (previews + choice offers)', 'A conversation-pane card that renders one OR MANY Phosphor glyphs by name/codepoint, each with an optional label and an optional description line, so icons can be previewed and compared in the live pane — and so a set of candidate icons can be offered as a labelled choice list (e.g. picking settings-scope icons, T-302).
## Why
Picking icons needs to SEE real glyphs side by side with what each one means. Frame0 can''t render Phosphor (private-use codepoints, no font) and goldens render the font as Ahem boxes, so preview only works where the app has the font — a native card is the vehicle. Beyond a bare grid, real icon decisions are ''which of these N icons, for these N meanings'' — so each entry wants an optional label (what we''d call it) and an optional description (what it represents), turning the card into an offer/choice list.
## Deliverable
A conversation-pane card (peer of the image card, T-249/T-252) that renders a list/grid of entries, each entry = glyph + optional label + optional description, driven by the clide CLI (D-6 parity). Uses the bundled Phosphor.ttf via PhosphorIconPainter.
### Multi-size rendering (per entry)
Each icon is shown at SEVERAL sizes, not one: (1) a large hero rendering so the glyph''s detail is clearly legible, and (2) a sample at each font-size token the app actually uses inline, so you can judge how the glyph reads at real UI sizes. The relevant inline scale is in lib/widgets/src/typography.dart: clideFontBadge (11), clideFontSmall (12), clideFontMeta (13), clideFontCaption/clideFontMono (14), clideFontBody (15). Drive the inline samples off those tokens (not bare numbers) so the row tracks the scale if it changes. For the hero, REUSE the existing clideFontWelcomeBanner (52) token rather than adding a new constant — it''s already the app''s named oversized size; no new token needed. Lay the size samples out in a single row/strip per entry, smallest to largest, labelled with the token/px so a reviewer sees exactly where each size lands.
### CLI shape + IPC wiring (mirror image.show exactly)
Follow the image-card template (lib/src/daemon/image_commands.dart) end to end — it is the proven D-6 parity pattern:
- REGISTRATION: a dotted `icon.show` command on DaemonDispatcher (invoked as `clide icon show`), declared with a CommandSchema — positional + per-arg ArgSpec — exactly like image.show''s `{positional: [''path''], args: {...}}`. Handler stays Flutter-free so it runs under `dart test`.
- BARE PREVIEW (variadic): one or more icons as positionals via ArgType.stringList — `clide icon show gear folder gauge` — each a kebab-case name (resolved by PhosphorIcons.byName) or a 0xNNNN codepoint. stringList is already supported by the schema (lib/src/ipc/command_schema.dart) and the argv parser, so no new CLI plumbing.
- LABELLED/DESCRIBED entries: a `--file <path.json>` flag whose value is a JSON array of `{"icon": "gear", "label": "Settings", "description": "global scope"}` (label, description optional); the handler reads and parses the file. NOTE: do NOT spec `--stdin` — clide''s CLI argv parser (lib/src/cli/argv_to_request.dart) only produces positionals/flags/passthrough and has no stdin path, so a `--file` flag (or repeated flags) is the grounded choice unless we deliberately add stdin support as separate work.
- RENDER PATH: validate + resolve icon names in the handler (inject a resolver the way image.show injects ImagePathResolver, so headless/dart-test stays filesystem-free), then publish on a dedicated MessageBus channel — e.g. `iconShowChannel = ''icon''`, peer of `imageShowChannel = ''image''` — captured post-boot in main.dart; the Claude extension subscribes to that literal and injects the card into the primary session''s conversation log. Honest failure (IpcError userError/notFound) on an unknown glyph name or a malformed/missing --file, and on no live UI bus (headless), mirroring image.show.
- One card per invocation; entries render as rows (or a grid when label/description are absent).
## Display-only card + interaction-zone selection (D-78 — decided)
DECIDED: the display card is display-only; the SELECTION happens in the convo box (interaction zone), not on the card. The card renders the labelled icon options for the user to SEE; when a pick is needed, Claude offers a matching choice list in the interaction zone (AskUserQuestion-style options that replace the composer), and the user selects there. This keeps conversation widgets display-only per D-78 and the interaction-zone rule.
The LABEL is the bridge between the two surfaces: Claude attaches a label to each icon on the display card, then offers the SAME labels as the options in the interaction-zone choice list — so ''I pick Settings'' in the convo box maps unambiguously back to the glyph the user saw on the card. That''s why per-entry labels are first-class here: they exist to facilitate this show-then-pick flow, with the description giving the extra context that doesn''t fit a one-word option. The card may still copy a codepoint/name on click (a convenience), but it never resolves the choice itself.
## Notes / scope
- Name->codepoint resolution ALREADY EXISTS: lib/widgets/src/icons/phosphor_glyphs.g.dart (generated, 1512 glyphs) + PhosphorIcons.byName. The earlier ''OPTIONAL: generate the full set'' caveat is resolved — every named glyph is already resolvable; the 49 curated consts in phosphor.dart remain curated sugar.
- Every glyph already renders via PhosphorIconPainter(0xNNNN).
- label and description are both optional per entry; an entry with neither degrades to the bare-preview look.
- Surfaced 2026-06-10 while choosing settings scope icons (T-302); refined 2026-06-10 to cover multi-icon labelled offer/choice lists.
## Acceptance
- `icon.show` is registered on DaemonDispatcher with a CommandSchema and invoked as `clide icon show`, mirroring image.show; the handler is Flutter-free and publishes on an `icon` MessageBus channel injected by the Claude extension.
- `clide icon show <name> [<name> ...]` accepts multiple icons (variadic stringList positionals) in one call and renders them in a single conversation card.
- A `--file <path.json>` payload lets each icon carry an optional label and optional description, both rendered alongside the glyph (no --stdin — not supported by the CLI parser).
- Each icon renders at multiple sizes: a hero at the existing clideFontWelcomeBanner (52) token plus one sample at each inline font-size token (badge 11 -> body 15), sized off the typography tokens and labelled so the reviewer sees legibility at real UI sizes.
- The card is display-only (no inline selection); selection happens in the interaction zone (convo box) via a Claude-offered choice list whose options reuse the per-icon labels from the card.
- Unknown/invalid icon names fail with a clear user error, not a blank glyph.
FOLLOW-UPS: the piped-JSON (--stdin) variant is split out as T-315 (generic CLI stdin plumbing); image.show gets the same metadata/annotation treatment in T-316. T-313 ships with --file regardless of T-315.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 10:20:54', '2026-06-10 11:11:10', NULL, '55c15feee383840b59617058ed513421', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ACSDBDZARV3NNGYD9NYYR', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'clide CLI: accept a JSON payload on stdin for structured commands', 'Add a stdin path to clide''s CLI so a command can receive a JSON payload piped in — `… | clide icon show --stdin`, `cat meta.json | clide image show foo.png --stdin` — instead of only positionals/flags or a `--file`.
## Why
Structured commands (the labelled icon-card entries in T-313, image annotation metadata in T-316) want a JSON payload that''s awkward to express as flags. Today clide''s CLI argv parser (lib/src/cli/argv_to_request.dart) only produces positionals, --flags, and `-- passthrough`; there is no stdin path. T-313 therefore falls back to a `--file <path.json>` flag. A `--stdin` convention is the ergonomic peer of `--file` for piping, and is shared infra both icon.show and image.show consume.
## Where the work lives
clide''s IPC server runs in-process and the `clide` CLI is a thin client that serialises argv into an IpcRequest over CLIDE_SOCK. So stdin must be slurped CLIENT-SIDE (in lib/src/cli/, around argv_to_request.dart / argv_dispatch.dart) and folded into the request before it is sent — the in-process handler never sees the real stdin. Decide how it surfaces in the envelope: e.g. a reserved `stdin`/`payload` field on IpcRequest, or a synthesised arg the CommandSchema can opt into (an ArgSpec flag like `acceptsStdin`, mirroring how ArgType.stringList is declared in lib/src/ipc/command_schema.dart).
## Scope / decisions
- Generic infra, not icon/image specific — once landed, any command opts in via its CommandSchema.
- Keep `--file` working; --stdin and --file should be mutually exclusive (error if both given) or layered with a defined precedence.
- Text/JSON payloads only to start; define a size cap and a clear error when --stdin is passed but stdin is empty/not a pipe (don''t hang waiting on a TTY).
- Honest IpcError (userError) on malformed JSON, surfaced like image.show''s other validation failures.
- D-6 parity: document the stdin convention alongside the other CLI verbs.
## Acceptance
- A command can declare (via CommandSchema) that it accepts a stdin payload; piping JSON in populates the IpcRequest with that payload.
- `clide icon show --stdin` (T-313) and `clide image show <path> --stdin` (T-316) both consume it.
- --stdin + --file together is a clear user error; --stdin with no piped input fails fast, never hangs on a TTY.
- Malformed JSON returns a userError with a helpful message.
Unblocks the piped-JSON variants of T-313 (icon entries) and T-316 (image annotations); both can also ship with --file independently of this.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 10:52:34', '2026-06-10 11:11:10', NULL, '69251007038985decbc3e18585f2d7b1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2AD3HPR3HXSVASVZEX8PK0', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'clide image show: JSON metadata payload so images can be annotated (label/description/markers)', 'Give `clide image show` the same structured-metadata plumbing the icon card gets (T-313), so an image driven into the conversation can carry ANNOTATIONS — not just a single one-line caption.
## Why
Today image.show (lib/src/daemon/image_commands.dart) takes a `path` positional plus an optional `--caption` string, and ImageMessage (lib/builtin/claude/src/transcript_reader.dart:160) holds only `path` + optional one-line `caption`. When showing a screenshot/wireframe to discuss, one line is thin — we want to attach a title/label, a longer description, and potentially captioned markers (callouts on specific spots). This mirrors the per-entry label+description the icon card introduces.
## Deliverable
Extend image.show to accept a JSON metadata payload (via the new stdin plumbing T-315 and/or a `--file <path.json>` flag, same as T-313''s icon entries) describing the image''s annotations, e.g.:
{ "path": "docs/shot.png", "label": "HUD v3", "description": "note the cramped status row", "caption": "before the fix" }
Extend ImageMessage + the image card to render the richer metadata. Keep the existing `clide image show <path> --caption "…"` form working unchanged (back-compat); the JSON payload is additive.
## Open decision — text vs visual annotation
''Annotated'' could mean (a) richer TEXT metadata shown around the image (label + description + caption), or (b) VISUAL overlays drawn ON the image (markers/arrows/numbered callouts at coordinates). Start with (a) — straightforward extension of the current card. (b) is a bigger, clide-owned CustomPaint job (markers:[{x,y,label}] painted over the image); feasible since the image card is our own rendering, but scope it as a follow-up unless we decide we need it now. Flag which we want before building markers.
## Dependencies
- Pairs with T-315 (stdin JSON plumbing) for the piped form; can land with `--file` alone if T-315 isn''t ready.
- Parallel to T-313 (icon card) — same --file/--stdin metadata pattern, same Flutter-free handler + MessageBus publish path.
## Acceptance
- `clide image show` accepts a JSON metadata payload (via --file, and via --stdin once T-315 lands) carrying at least label + description alongside the existing caption.
- ImageMessage + the image card render the added metadata.
- The existing `image show <path> [--caption]` form is unchanged.
- Malformed payload / unknown fields fail with a clear userError, mirroring image.show''s current validation.
- Visual marker overlays are explicitly noted as a separate follow-up unless pulled in by decision.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 10:52:36', '2026-06-10 11:11:10', NULL, 'ab80fa0823568f0d159b6a9d162114b2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ERREMEEF26KKHGNZBWW64', 'story', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Drawing card core: canvas engine + JSON schema + template dispatch', 'Foundational build for the unified drawing card (epic T-317, decision D-91). A clide-owned canvas (CustomPaint) that renders from a JSON document: a PRIMITIVE scene-graph layer (rect/line/text/glyph/image at coordinates) plus a TEMPLATE-dispatch layer that maps a named component in the JSON to a predefined renderer. Templates lower onto the same primitive scene (hybrid model). Per drawn object, an optional label + description widget renders beneath it, only when those fields are present in the JSON. Display-only (D-78) — no inline selection. Driven via the clide CLI (D-6 parity), consuming the JSON input plumbing (T-315 / --file); mirror image.show''s Flutter-free handler + MessageBus publish + Claude-extension injection pattern. THIS ticket = the engine, the JSON schema, the primitive renderer, the dispatch mechanism, and the shared per-object label/description widget. Individual templates (image, icon, compare, svg, graph) are separate children. Acceptance: a JSON doc with raw primitives draws; a JSON doc naming a template dispatches to it; an object with label/description renders the caption widget; unknown template/primitive fails with a clear userError.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 11:11:40', '2026-06-10 11:11:40', NULL, '73af1f59dfa92fcb13073cd31a73e2f4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ESCR4V6V07CRH18FBCDN8', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Drawing card template: compare-images / before-after', 'Template for the unified drawing card (T-317, D-91): compare-images / before-after. The JSON carries two (or more) image paths, each with an optional label + description; the card renders them side by side for visual comparison, each with a lightbox-on-click (shared with the image template). Lowers onto the canvas primitives + the shared per-object label/description widget (T-318). Display-only (D-78). Depends on the core engine (T-318).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 11:11:45', '2026-06-10 11:11:45', NULL, '5d2d21c180849ce0bb652d79855c40af', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ETJQP0CT6X7W3CWZ6NS9G', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Drawing card template: SVG render', 'Template for the unified drawing card (T-317, D-91): render an SVG file referenced in the JSON. clide-owned rendering per the own-the-rendering-stack guardrail — no opinionated SVG package without justification; evaluate what is feasible via CustomPaint / a minimal path parser, and scope accordingly (SVG is a broad spec; define the supported subset). Optional label + description beneath (T-318). Display-only (D-78). Depends on the core engine (T-318).', 'backlog', 'low', NULL, NULL, NULL, '2026-06-10 11:11:55', '2026-06-10 11:11:55', NULL, 'aee93f5693c771bedec77e288a2784b1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2EV29HSK6EJ5VF50R87VC4', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Drawing card template: graph render', 'Template for the unified drawing card (T-317, D-91): render a graph passed in the JSON. Reuses clide''s native graph rendering (CustomPaint; the planned graph subsystem, D-46) rather than a new renderer. Optional label + description beneath (T-318). Display-only (D-78). Depends on the core engine (T-318); coordinate with the graph subsystem so the card embeds it rather than forking it.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-10 11:11:59', '2026-06-10 11:11:59', NULL, 'd21ba3f471f6518d5775008897e67416', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB16YM1Y910T07Y1Y22MGTEM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Card the ''context'' conversation block like the rest of the cards', 'The injected "context" block (skill-load / command-expansion / system-reminder messages) renders as a frameless `ConversationCardVariant.bare` card in `lib/builtin/claude/src/conversation_view.dart:410-419`, so it reads as a bare `> context …` row wedged between the fully-framed Skill and Bash tool cards (see pasted screenshot). It looks unfinished next to the carded tool calls.
Make the context block follow the standard collapser-card pattern established for all group/tool cards in the `collapser-card` wireframe (docs/design/wireframes/cards/): a proper card frame with the chevron toggle hard against the left edge, label, and the fixed-width right-aligned counter/status slot — so it is visually consistent with the Skill/Bash/Activity cards around it.
Keep the de-emphasis that D-78 calls for (muted accent, collapsed-by-default, first-line summary) — this is about giving it the same *frame* as the other cards, not the blue "you" accent. The same treatment likely applies to the sibling bare cards that share this branch (agent prompt, thinking) for consistency; scope to context first and note whether thinking/agent-prompt should follow.
Acceptance: the context row sits in a framed card matching the collapser-card wireframe geometry, still muted and collapsed by default, with the toggle and counter/status slot aligned to the other cards. Update/extend the relevant golden(s).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 08:17:42', '2026-06-10 11:16:41', NULL, '5b583df100b64dbebe19c235f32e0541', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G1WD1839Z90AQ5C0BHNV4', 'story', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Tier-5 canvas pane (builtin.canvas)', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:17', '2026-06-10 11:17:17', NULL, 'a2f956a7f232bf7cd3d1e05dc679f1d7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'story', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Tier-5 graph view (builtin.graph)', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:23', '2026-06-10 11:17:23', NULL, 'cd97babed081736efcba6756ffec2ead', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7162Z26FDWXG35VH0', 'epic', NULL, 'Tier 5 — canvas and graph view', 'Tier 5 of the build plan: bring the canvas pane (Obsidian-style spatial whiteboard) and the graph view (force-directed view of vault links) to feature parity.
**Canvas pane (`builtin.canvas`):**
- Render `.canvas` files (Obsidian-compatible JSON) — nodes (note, text, group, image), edges, layout state.
- Pan/zoom via `InteractiveViewer`; node selection, drag, resize.
- Edit affordances: add note from file picker, add text node, draw edge between nodes.
- Persist layout back to the `.canvas` file on disk.
**Graph view (`builtin.graph`):**
- Force-directed layout of the current vault (or a filtered subset) — nodes are notes, edges are wikilinks.
- Hover highlights connected subgraph; click opens the note in the editor.
- Filter pane: tag include/exclude, file glob, depth-from-active.
- pql provides the link data (`pql backlinks` / `pql outlinks`); rendering owned in-app.
Both panes use the existing `MultitabPane` (T-83) for tab management once they ship. Each lives in its own slot per D-47 (canvas in workspace, graph in context panel).
MERGED (2026-06-10) into the unified canvas epic T-317 (decision D-91). The canvas rendering work consolidates onto one clide-owned canvas renderer driven by JSON; .canvas becomes an import format converted into that JSON rather than a native schema. T-7''s scope is preserved as children of T-317: canvas pane -> T-322, graph view -> T-323. This epic is cancelled as superseded; track the work under T-317.', 'backlog', 'medium', NULL, NULL, 'D-17', '2026-04-22 11:45:41', '2026-06-10 11:17:31', NULL, 'ff3683fb066e070dda96af9c5eeaf943', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM67JSC5RKS6M9182KG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Add images tab to context panel', 'Spec lists images as a right-panel section. No extension exists yet.', 'backlog', 'low', NULL, NULL, 'D-47', '2026-04-22 21:03:40', '2026-06-10 11:18:12', NULL, '17e5d66435d973f3771be7ac93104262', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7162Z26FDWXG35VH0', 'epic', NULL, 'Tier 5 — canvas and graph view', 'Tier 5 of the build plan: bring the canvas pane (Obsidian-style spatial whiteboard) and the graph view (force-directed view of vault links) to feature parity.
**Canvas pane (`builtin.canvas`):**
- Render `.canvas` files (Obsidian-compatible JSON) — nodes (note, text, group, image), edges, layout state.
- Pan/zoom via `InteractiveViewer`; node selection, drag, resize.
- Edit affordances: add note from file picker, add text node, draw edge between nodes.
- Persist layout back to the `.canvas` file on disk.
**Graph view (`builtin.graph`):**
- Force-directed layout of the current vault (or a filtered subset) — nodes are notes, edges are wikilinks.
- Hover highlights connected subgraph; click opens the note in the editor.
- Filter pane: tag include/exclude, file glob, depth-from-active.
- pql provides the link data (`pql backlinks` / `pql outlinks`); rendering owned in-app.
Both panes use the existing `MultitabPane` (T-83) for tab management once they ship. Each lives in its own slot per D-47 (canvas in workspace, graph in context panel).
MERGED (2026-06-10) into the unified canvas epic T-317 (decision D-91). The canvas rendering work consolidates onto one clide-owned canvas renderer driven by JSON; .canvas becomes an import format converted into that JSON rather than a native schema. T-7''s scope is preserved as children of T-317: canvas pane -> T-322, graph view -> T-323. This epic is cancelled as superseded; track the work under T-317.', 'cancelled', 'medium', NULL, NULL, 'D-17', '2026-04-22 11:45:41', '2026-06-10 11:18:15', NULL, '3e558a94afc3e675e673c6e9f9390855', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1ZFJK6J2GSV4SA69QF730C', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Image card caches by path — overwritten file shows stale render', 'clide image show (and the conversation image viewer card) appears to cache by file path: re-showing a file after its bytes change at the SAME path renders the previous image, not the new one. Hit live while iterating on a Frame0 wireframe (re-exported settings-modal.png in place; the pane kept showing the prior version until exported under a new filename).
Expected: showing a path always reflects the current file bytes.
Explore / options:
- Cache-key on mtime + size (or a content hash) instead of path alone; invalidate when they change.
- A force-refresh path for clide image show (re-decode, bypass cache).
- Check where the cache lives: the image card widget (Image.file / ImageProvider cache — Flutter''s imageCache keys by path+scale and won''t refresh on overwrite), the IPC image.show handler, or the pane render layer.
- Likely Flutter imageCache: evict the key (or use a FileImage with a key that includes mtime) before showing.
Note: surfaced via clide image show (T-249 / T-252 lightbox share the same provider). Repro: export an image, show it, overwrite the same path with different content, show again -> stale.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 10:04:53', '2026-06-10 11:25:58', NULL, 'e0db833047273042eaa96ba6b54bed8d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2J2HWD66QAFDDRRWS5NM48', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Tab strip feels cramped against the chrome above — add 1px margin on top', 'The tab strip sits flush against the chrome/border directly above it, which reads as cramped (user screenshot: the ''primary'' / ''session 1'' tabs butting up against the top edge). Add ~1px of breathing room above the tab strip.
The tab strip is ClideTabBar (lib/widgets/src/clide_tab_bar.dart) — a Container with a fixed height + tabBarBackground color, no top margin/padding. Options: add a 1px top margin on the ClideTabBar Container, or have the host that places it (e.g. multitab_pane.dart / the pane chrome) reserve 1px above. Prefer doing it where it won''t double-apply across the several surfaces that embed a tab bar (editor tabs, sidebar tabs, session tabs) — confirm it looks right in each, not just the Claude session host in the screenshot.
Keep it a hairline (1px) so it relieves the cramping without shifting the layout noticeably. Watch the tabBarBackground vs the surface behind it so the 1px gap shows the intended color, not a seam.
Acceptance: the tab strip has 1px of space above it; tabs no longer butt against the chrome edge; checked across the surfaces that use ClideTabBar.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-10 11:26:07', '2026-06-10 11:26:07', NULL, '7b1fc03526ce721346981e261e130b8f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB234WP4Y6Q16A0HFW8BSXMG', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Native Phosphor glyph card — multi-icon list with optional labels/descriptions (previews + choice offers)', 'A conversation-pane card that renders one OR MANY Phosphor glyphs by name/codepoint, each with an optional label and an optional description line, so icons can be previewed and compared in the live pane — and so a set of candidate icons can be offered as a labelled choice list (e.g. picking settings-scope icons, T-302).
## Why
Picking icons needs to SEE real glyphs side by side with what each one means. Frame0 can''t render Phosphor (private-use codepoints, no font) and goldens render the font as Ahem boxes, so preview only works where the app has the font — a native card is the vehicle. Beyond a bare grid, real icon decisions are ''which of these N icons, for these N meanings'' — so each entry wants an optional label (what we''d call it) and an optional description (what it represents), turning the card into an offer/choice list.
## Deliverable
A conversation-pane card (peer of the image card, T-249/T-252) that renders a list/grid of entries, each entry = glyph + optional label + optional description, driven by the clide CLI (D-6 parity). Uses the bundled Phosphor.ttf via PhosphorIconPainter.
### Multi-size rendering (per entry)
Each icon is shown at SEVERAL sizes, not one: (1) a large hero rendering so the glyph''s detail is clearly legible, and (2) a sample at each font-size token the app actually uses inline, so you can judge how the glyph reads at real UI sizes. The relevant inline scale is in lib/widgets/src/typography.dart: clideFontBadge (11), clideFontSmall (12), clideFontMeta (13), clideFontCaption/clideFontMono (14), clideFontBody (15). Drive the inline samples off those tokens (not bare numbers) so the row tracks the scale if it changes. For the hero, REUSE the existing clideFontWelcomeBanner (52) token rather than adding a new constant — it''s already the app''s named oversized size; no new token needed. Lay the size samples out in a single row/strip per entry, smallest to largest, labelled with the token/px so a reviewer sees exactly where each size lands.
### CLI shape + IPC wiring (mirror image.show exactly)
Follow the image-card template (lib/src/daemon/image_commands.dart) end to end — it is the proven D-6 parity pattern:
- REGISTRATION: a dotted `icon.show` command on DaemonDispatcher (invoked as `clide icon show`), declared with a CommandSchema — positional + per-arg ArgSpec — exactly like image.show''s `{positional: [''path''], args: {...}}`. Handler stays Flutter-free so it runs under `dart test`.
- BARE PREVIEW (variadic): one or more icons as positionals via ArgType.stringList — `clide icon show gear folder gauge` — each a kebab-case name (resolved by PhosphorIcons.byName) or a 0xNNNN codepoint. stringList is already supported by the schema (lib/src/ipc/command_schema.dart) and the argv parser, so no new CLI plumbing.
- LABELLED/DESCRIBED entries: a `--file <path.json>` flag whose value is a JSON array of `{"icon": "gear", "label": "Settings", "description": "global scope"}` (label, description optional); the handler reads and parses the file. NOTE: do NOT spec `--stdin` — clide''s CLI argv parser (lib/src/cli/argv_to_request.dart) only produces positionals/flags/passthrough and has no stdin path, so a `--file` flag (or repeated flags) is the grounded choice unless we deliberately add stdin support as separate work.
- RENDER PATH: validate + resolve icon names in the handler (inject a resolver the way image.show injects ImagePathResolver, so headless/dart-test stays filesystem-free), then publish on a dedicated MessageBus channel — e.g. `iconShowChannel = ''icon''`, peer of `imageShowChannel = ''image''` — captured post-boot in main.dart; the Claude extension subscribes to that literal and injects the card into the primary session''s conversation log. Honest failure (IpcError userError/notFound) on an unknown glyph name or a malformed/missing --file, and on no live UI bus (headless), mirroring image.show.
- One card per invocation; entries render as rows (or a grid when label/description are absent).
## Display-only card + interaction-zone selection (D-78 — decided)
DECIDED: the display card is display-only; the SELECTION happens in the convo box (interaction zone), not on the card. The card renders the labelled icon options for the user to SEE; when a pick is needed, Claude offers a matching choice list in the interaction zone (AskUserQuestion-style options that replace the composer), and the user selects there. This keeps conversation widgets display-only per D-78 and the interaction-zone rule.
The LABEL is the bridge between the two surfaces: Claude attaches a label to each icon on the display card, then offers the SAME labels as the options in the interaction-zone choice list — so ''I pick Settings'' in the convo box maps unambiguously back to the glyph the user saw on the card. That''s why per-entry labels are first-class here: they exist to facilitate this show-then-pick flow, with the description giving the extra context that doesn''t fit a one-word option. The card may still copy a codepoint/name on click (a convenience), but it never resolves the choice itself.
## Notes / scope
- Name->codepoint resolution ALREADY EXISTS: lib/widgets/src/icons/phosphor_glyphs.g.dart (generated, 1512 glyphs) + PhosphorIcons.byName. The earlier ''OPTIONAL: generate the full set'' caveat is resolved — every named glyph is already resolvable; the 49 curated consts in phosphor.dart remain curated sugar.
- Every glyph already renders via PhosphorIconPainter(0xNNNN).
- label and description are both optional per entry; an entry with neither degrades to the bare-preview look.
- Surfaced 2026-06-10 while choosing settings scope icons (T-302); refined 2026-06-10 to cover multi-icon labelled offer/choice lists.
## Acceptance
- `icon.show` is registered on DaemonDispatcher with a CommandSchema and invoked as `clide icon show`, mirroring image.show; the handler is Flutter-free and publishes on an `icon` MessageBus channel injected by the Claude extension.
- `clide icon show <name> [<name> ...]` accepts multiple icons (variadic stringList positionals) in one call and renders them in a single conversation card.
- A `--file <path.json>` payload lets each icon carry an optional label and optional description, both rendered alongside the glyph (no --stdin — not supported by the CLI parser).
- Each icon renders at multiple sizes: a hero at the existing clideFontWelcomeBanner (52) token plus one sample at each inline font-size token (badge 11 -> body 15), sized off the typography tokens and labelled so the reviewer sees legibility at real UI sizes.
- The card is display-only (no inline selection); selection happens in the interaction zone (convo box) via a Claude-offered choice list whose options reuse the per-icon labels from the card.
- Unknown/invalid icon names fail with a clear user error, not a blank glyph.
FOLLOW-UPS: the piped-JSON (--stdin) variant is split out as T-315 (generic CLI stdin plumbing); image.show gets the same metadata/annotation treatment in T-316. T-313 ships with --file regardless of T-315.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 10:20:54', '2026-06-10 11:29:04', NULL, 'c09cb1299d29d33380f6f33d05528345', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2J2HWD66QAFDDRRWS5NM48', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Tab strip feels cramped against the chrome above — add 1px margin on top', 'The tab strip sits flush against the chrome/border directly above it, which reads as cramped (user screenshot: the ''primary'' / ''session 1'' tabs butting up against the top edge). Add ~1px of breathing room above the tab strip.
The tab strip is ClideTabBar (lib/widgets/src/clide_tab_bar.dart) — a Container with a fixed height + tabBarBackground color, no top margin/padding. Options: add a 1px top margin on the ClideTabBar Container, or have the host that places it (e.g. multitab_pane.dart / the pane chrome) reserve 1px above. Prefer doing it where it won''t double-apply across the several surfaces that embed a tab bar (editor tabs, sidebar tabs, session tabs) — confirm it looks right in each, not just the Claude session host in the screenshot.
Keep it a hairline (1px) so it relieves the cramping without shifting the layout noticeably. Watch the tabBarBackground vs the surface behind it so the 1px gap shows the intended color, not a seam.
Acceptance: the tab strip has 1px of space above it; tabs no longer butt against the chrome edge; checked across the surfaces that use ClideTabBar.', 'ready', 'low', NULL, NULL, NULL, '2026-06-10 11:26:07', '2026-06-10 11:29:10', NULL, 'a41dd77ce4d26779d83e385a0701a2a8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1XDWKQ594ET4GDYEFK5ZJ4', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Permission card: add ''Deny & simplify'' option that denies with a retry-simpler note', 'Add a fourth option to the Claude permission prompt card (lib/builtin/claude/src/prompt_card.dart) alongside Allow / Allow & don''t ask again / Deny.
WHAT IT DOES
A deny that carries a preformatted follow-up note telling Claude the action was too complex for the permission system and to retry in a simpler format. Implemented as a _permDeny variant that passes a fixed note into DenyTool (today _permDeny uses _permNote() ?? ''Denied by the user.'' at prompt_card.dart:202). The user''s own note field, if filled, should still be respected — append it to / combine with the preformatted text rather than discard it.
LABEL: ''Deny & simplify'' (working label; placed after Deny).
TOOLTIP: ClideButton already supports (clide_button.dart:26) — add a mouseover explaining the behavior, e.g. ''Deny and ask Claude to retry this action in a simpler format — complex interactions don''t work well with the permission system.''
PREFORMATTED DENY NOTE (workshop wording, starting point):
"Denied — this action is too complex for the permission system to approve cleanly. Please retry with a simpler, more granular approach (break it into smaller steps or use a plainer command) to avoid this permission prompt. This is a one-off for THIS action only: do not add a memory and do not change permission settings — just reformulate and try again."
The ''do not add a memory / do not change permission settings'' clause is deliberate: without it Claude tends to ''fix'' the permission system (writing memories, rewriting permission config), which means continuous fiddling with a surface we don''t want it touching.
NUMBER-KEY SLOT
_activateNumber (prompt_card.dart:161-164) maps 1=Allow, 2=Allow&remember (when canRemember), 3/2=Deny. Add the new option as the next index (4 when canRemember, else 3). Keep Deny as its own option; the new one is additive. Update the digit/numpad shortcut mapping accordingly (see also T-310 numpad parity).
DESIGN NOTE — escalation behavior
The deny note enters the conversation and stays in context, so repeated use within one session compounds (Claude leans progressively harder toward simpler formats). That''s largely the intended escalating pressure, but the note is phrased as a one-shot ''retry THIS action'' rather than a standing rule to limit over-correction. Worth watching in testing whether repeated denials over-bias toward trivial formats.
ACCEPTANCE
- A fourth button ''Deny & simplify'' appears on the permission card with a tooltip.
- Activating it resolves the prompt as a deny whose note is the preformatted retry-simpler text (with the user''s typed note appended when present).
- The note explicitly tells Claude not to add a memory or change permission settings.
- Number-row and numpad digit shortcuts address the new option in the correct slot.
- Widget test covers the new button resolving to a DenyTool with the expected note.
CLARIFICATION (the TOOLTIP line above lost a word to shell escaping): ClideButton already exposes a tooltip parameter (clide_button.dart:17,26,42) — pass tooltip on the new button for the mouseover; no widget change needed.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 09:55:55', '2026-06-10 11:33:18', NULL, '99cabbaf7cef885905c6e3d44c8f9441', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1Q1Y3CYJHD7W5F68VDB6T4', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Surface Claude''s task list docked above the bottom input pane', 'Claude maintains a task/todo list during a session (the TodoWrite / TaskCreate checklist), but clide never shows it — it is completely invisible to the user. The transcript reader (lib/builtin/claude/src/transcript_reader.dart:205-212) skips the relevant event types and there is no model class for task items; the conversation view (conversation_view.dart) renders none. So the user has no view of what Claude is tracking or how far along a multi-step task is.
Surface it as a compact, always-current task list DOCKED to the bottom input pane of the Claude conversation view. Per the Explore: the pane is a Column in claude_pane.dart (_ClaudePaneState.build, ~line 474-542) with ConversationView (Expanded) on top and the ToolPromptCard/ClaudeComposer at the bottom (~line 519-525); the task list should sit between them — directly above the composer (claude_composer.dart), non-expanded — so it stays pinned and visible as Claude works.
Scope:
- PARSE: model Claude''s task list from the transcript. Find the event type carrying the todo/task items (currently among the skipped types in transcript_reader.dart) and add a TaskList model + items (text + status: pending/in_progress/completed). Latest-write-wins — the list reflects the most recent todo state, not an append log.
- RENDER: a docked task-list widget above the composer. Compact: collapsed/summary by default (e.g. ''N tasks · M done'' + current in_progress item), expandable to the full checklist. Per-item status glyph (pending/in-progress/done) using existing tokens + Phosphor icons (ui-design skill). Hide entirely when there are no tasks.
- Keep it display-only and de-emphasised so it never competes with the composer for attention; honour D-78 (interaction zone) — this is a display surface, not an interactive control.
- a11y: list semantics, status announced per item.
Acceptance:
1. When Claude has an active task list, it shows docked directly above the input pane and updates live as items change state.
2. Empty/no-task sessions show nothing (no empty chrome).
3. Collapsed by default with a one-line summary; expandable to the full list.
4. Display-only; the composer keeps full function and focus behaviour.
5. Tests: transcript_reader parses the task-list event into the model (incl. status transitions + latest-wins); widget renders states + collapse/expand + hidden-when-empty.
Refs: conversation card patterns (conversation_card.dart), D-78 (interaction zone), ui-design skill (tokens/icons).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 09:28:04', '2026-06-10 11:33:26', NULL, '72831c0e631550beddea279fa12f0657', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4KS233FGZE9H7ABWR', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Activity-card spinner too small to make out — enlarge it', 'The logo-mark spinner shown on in-progress activity/holder cards in the Claude conversation is too small to read as a spinner — it reads as a static speck. Enlarge it so the running state is legible at a glance.
**Where**
- `ClideSpinner` (lib/widgets/src/clide_spinner.dart) — defaults to size 14; renders the logo SVG at width/height = size.
- `ClideStatusIndicator` (lib/widgets/src/clide_status_indicator.dart) — default size 14; maps running→ClideSpinner, success→check, error→cross at the same size.
- Call sites: holder_card.dart:117 and :199 pass `size: 12` — the small value the user is seeing.
**Direction (settle in review)**
- Bump the spinner size on the activity cards (the `size: 12` call sites, and/or the indicator default) to something clearly legible — pull a concrete value from the ui-design control-geometry tokens rather than a magic number.
- Keep the running spinner, success check, and error cross visually balanced at the new size (they share `size`), so the card doesn''t jump when the state settles.
- Check the other ClideSpinner/StatusIndicator consumers (status surfaces) so the bump doesn''t bloat unrelated spots — may warrant sizing the cards explicitly rather than changing the shared default.
**Acceptance**
- The in-progress spinner on conversation activity cards is comfortably distinguishable as a spinning indicator; success/error glyphs stay aligned at the same footprint.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-09 21:22:28', '2026-06-10 11:33:35', NULL, 'a4e3c0840be6bc811fb82308051e6239', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2J2HWD66QAFDDRRWS5NM48', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Tab strip feels cramped against the chrome above — add 1px margin on top', 'The tab strip sits flush against the chrome/border directly above it, which reads as cramped (user screenshot: the ''primary'' / ''session 1'' tabs butting up against the top edge). Add ~1px of breathing room above the tab strip.
The tab strip is ClideTabBar (lib/widgets/src/clide_tab_bar.dart) — a Container with a fixed height + tabBarBackground color, no top margin/padding. Options: add a 1px top margin on the ClideTabBar Container, or have the host that places it (e.g. multitab_pane.dart / the pane chrome) reserve 1px above. Prefer doing it where it won''t double-apply across the several surfaces that embed a tab bar (editor tabs, sidebar tabs, session tabs) — confirm it looks right in each, not just the Claude session host in the screenshot.
Keep it a hairline (1px) so it relieves the cramping without shifting the layout noticeably. Watch the tabBarBackground vs the surface behind it so the 1px gap shows the intended color, not a seam.
Acceptance: the tab strip has 1px of space above it; tabs no longer butt against the chrome edge; checked across the surfaces that use ClideTabBar.', 'done', 'low', NULL, NULL, NULL, '2026-06-10 11:26:07', '2026-06-10 11:36:35', NULL, '06aaa8609442446ebb98cd83cf241450', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4KS233FGZE9H7ABWR', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Activity-card spinner too small to make out — enlarge it', 'The logo-mark spinner shown on in-progress activity/holder cards in the Claude conversation is too small to read as a spinner — it reads as a static speck. Enlarge it so the running state is legible at a glance.
**Where**
- `ClideSpinner` (lib/widgets/src/clide_spinner.dart) — defaults to size 14; renders the logo SVG at width/height = size.
- `ClideStatusIndicator` (lib/widgets/src/clide_status_indicator.dart) — default size 14; maps running→ClideSpinner, success→check, error→cross at the same size.
- Call sites: holder_card.dart:117 and :199 pass `size: 12` — the small value the user is seeing.
**Direction (settle in review)**
- Bump the spinner size on the activity cards (the `size: 12` call sites, and/or the indicator default) to something clearly legible — pull a concrete value from the ui-design control-geometry tokens rather than a magic number.
- Keep the running spinner, success check, and error cross visually balanced at the new size (they share `size`), so the card doesn''t jump when the state settles.
- Check the other ClideSpinner/StatusIndicator consumers (status surfaces) so the bump doesn''t bloat unrelated spots — may warrant sizing the cards explicitly rather than changing the shared default.
**Acceptance**
- The in-progress spinner on conversation activity cards is comfortably distinguishable as a spinning indicator; success/error glyphs stay aligned at the same footprint.
**Initial trial**
- For the first cut, double the current size: the `size: 12` activity-card call sites go to `size: 24`. Trial that footprint, then settle the final value in review against the control-geometry tokens.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-09 21:22:28', '2026-06-10 11:55:15', NULL, 'f1b11537f6c416bae19a6da5447a384e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2T11GCV1EV07DYD5BZENTM', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Live tail sub-card inside expanded Bash activity cards', 'Expanded Bash activity cards should be able to show a live, scrolling tail of a file-backed source the command is following, so the user can watch the same output Claude is tailing — connected lazily, only while the card is expanded.
**Motivation**
Long-running / tailing Bash steps render only a final result block once the command finishes (see screenshot in T-304 thread). The user can''t follow progress. We want an embedded scrolling terminal sub-card inside the expanded activity card for visibility.
**Hard constraint (feasibility, established by investigation)**
Claude Code CLI executes every Bash tool itself; clide only ingests stream-json events (stream_json_session.dart -> parseTranscriptChunk). A tool_result arrives as ONE complete block — clide never sees in-flight tool output, and the controller''s in-place upsert path is hardcoded for `partial-<msg_id>` prose deltas, not tool results (controller.dart). We cannot tap Claude''s subprocess fd. So a fully-general "mirror the running command''s stdout" is NOT possible.
**Viable scope: independent file-tail on expand**
- On card expand, parse the Bash command string (AssistantToolUse.input["command"], transcript_reader.dart) for a file-backed source: `tail -f X`, `tail -n N X`, `cat X`, `less X`, known log paths, or a background task writing to a file.
- If found, clide opens its OWN read-only follower — a NativePty running `tail -f X` (native_pty.dart already exposes Stream<Uint8List>) or a Dart file-watcher — and renders it in an embedded scrolling terminal sub-card, reusing TerminalView.
- Lazy lifecycle: connect on expand, disconnect/dispose on collapse. No connection until expanded.
- Read-only & safe: clide observes the same file; it never re-runs or intercepts Claude''s command.
- Graceful when there is no file-backed source (e.g. `git push ... | tail -25`, a pipe inside Claude''s process): show a muted "no independent source to follow" note rather than faking output.
**Where**
- Card body: conversation_card.dart / conversation_view.dart (_toolUseCollapser, the Bash tool card). Add an optional live-tail segment below the RESULT segment.
- Key the live connection on AssistantToolUse.toolUseId.
- Embedded terminal: reuse lib/src/terminal TerminalView + lib/src/pty NativePty.
**Open questions (settle in review / may need a Q-record)**
- Command-parsing surface: which commands/forms count as "tailable"? Keep it a small, explicit allowlist to avoid mis-following.
- Security: only follow paths inside the workspace? How to handle absolute paths outside the repo.
- Lifecycle when the underlying command has already finished (file static) vs still running.
**Acceptance**
- Expanding a Bash card whose command tails a file shows a live scrolling terminal sub-card that updates as the file grows; collapsing it tears the follower down.
- A Bash card with no file-backed source shows a clear "nothing to follow" affordance, never a broken/empty terminal.
- No follower process is spawned until the card is expanded.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 12:00:52', '2026-06-10 12:00:52', NULL, '96c66c13b16a312fa2ae625817269bb0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2TY91VHK7TPKPMZ11EG3TM', 'bug', NULL, 'ClideStatusIndicator: duplicate ''running'' key crashes AnimatedSwitcher on rapid status flips', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 12:04:51', '2026-06-10 12:04:51', NULL, '6543e76a19006cfc6cd6ff85cd2c2d6e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4KS233FGZE9H7ABWR', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Activity-card spinner too small to make out — enlarge it', 'The logo-mark spinner shown on in-progress activity/holder cards in the Claude conversation is too small to read as a spinner — it reads as a static speck. Enlarge it so the running state is legible at a glance.
**Where**
- `ClideSpinner` (lib/widgets/src/clide_spinner.dart) — defaults to size 14; renders the logo SVG at width/height = size.
- `ClideStatusIndicator` (lib/widgets/src/clide_status_indicator.dart) — default size 14; maps running→ClideSpinner, success→check, error→cross at the same size.
- Call sites: holder_card.dart:117 and :199 pass `size: 12` — the small value the user is seeing.
**Direction (settle in review)**
- Bump the spinner size on the activity cards (the `size: 12` call sites, and/or the indicator default) to something clearly legible — pull a concrete value from the ui-design control-geometry tokens rather than a magic number.
- Keep the running spinner, success check, and error cross visually balanced at the new size (they share `size`), so the card doesn''t jump when the state settles.
- Check the other ClideSpinner/StatusIndicator consumers (status surfaces) so the bump doesn''t bloat unrelated spots — may warrant sizing the cards explicitly rather than changing the shared default.
**Acceptance**
- The in-progress spinner on conversation activity cards is comfortably distinguishable as a spinning indicator; success/error glyphs stay aligned at the same footprint.
**Initial trial**
- For the first cut, double the current size: the `size: 12` activity-card call sites go to `size: 24`. Trial that footprint, then settle the final value in review against the control-geometry tokens.', 'done', 'medium', NULL, NULL, NULL, '2026-06-09 21:22:28', '2026-06-10 12:05:17', NULL, '1266a6e25abae600315c65aefd7d8f93', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2TY91VHK7TPKPMZ11EG3TM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'ClideStatusIndicator: duplicate ''running'' key crashes AnimatedSwitcher on rapid status flips', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 12:04:51', '2026-06-10 12:05:21', NULL, 'c6de783bb8c7c95fa548861ec4c74d4f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2TY91VHK7TPKPMZ11EG3TM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'ClideStatusIndicator: duplicate ''running'' key crashes AnimatedSwitcher on rapid status flips', 'AnimatedSwitcher in ClideStatusIndicator throws "Duplicate keys found" (Stack has multiple children with key [<''running''>]) during normal app run, cascading into a flood of follow-on errors ("Tried to build dirty widget in the wrong build scope", "debugNeedsLayout is not true", "ScrollController attached to multiple scroll views", etc).
Location: lib/widgets/src/clide_status_indicator.dart:37 (AnimatedSwitcher at build()).
Root cause: each status maps to a child with a fixed ValueKey (''running'' / ''success'' / ''error''). AnimatedSwitcher cross-fades the outgoing and incoming child inside a Stack for its 200ms duration. When the status flips back to a value whose previous child is still animating out (e.g. running -> success -> running within 200ms, or repeated running rebuilds), the still-exiting child and the new child both carry ValueKey(''running'') and collide in the Stack -> duplicate-key assertion. The downstream exceptions are the framework unwinding from the failed build.
Repro: observed live during `make run` with two Claude panes bound (primary + secondary-1); status indicators flipping quickly trigger it.
Fix direction: the ValueKey must be unique per indicator instance, not just per status, so two instances (or an in-flight transition) never share a key. Options: key by status combined with a stable per-widget id, or drop the const keys and let AnimatedSwitcher key on child type. Add a widget test that rapidly toggles status within the switch duration and pumps mid-transition to guard the regression.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 12:04:51', '2026-06-10 12:05:39', NULL, '36855b40b1014791247074c28862e0cd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2TY91VHK7TPKPMZ11EG3TM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'ClideStatusIndicator: duplicate ''running'' key crashes AnimatedSwitcher on rapid status flips', 'AnimatedSwitcher in ClideStatusIndicator throws "Duplicate keys found" (Stack has multiple children with key [<''running''>]) during normal app run, cascading into a flood of follow-on errors ("Tried to build dirty widget in the wrong build scope", "debugNeedsLayout is not true", "ScrollController attached to multiple scroll views", etc).
Location: lib/widgets/src/clide_status_indicator.dart:37 (AnimatedSwitcher at build()).
Root cause: each status maps to a child with a fixed ValueKey (''running'' / ''success'' / ''error''). AnimatedSwitcher cross-fades the outgoing and incoming child inside a Stack for its 200ms duration. When the status flips back to a value whose previous child is still animating out (e.g. running -> success -> running within 200ms, or repeated running rebuilds), the still-exiting child and the new child both carry ValueKey(''running'') and collide in the Stack -> duplicate-key assertion. The downstream exceptions are the framework unwinding from the failed build.
Repro: observed live during `make run` with two Claude panes bound (primary + secondary-1); status indicators flipping quickly trigger it.
Fix direction: the ValueKey must be unique per indicator instance, not just per status, so two instances (or an in-flight transition) never share a key. Options: key by status combined with a stable per-widget id, or drop the const keys and let AnimatedSwitcher key on child type. Add a widget test that rapidly toggles status within the switch duration and pumps mid-transition to guard the regression.', 'ready', 'high', NULL, NULL, NULL, '2026-06-10 12:04:51', '2026-06-10 12:07:39', NULL, '3f833daa3bb004ea42b2f0d70f6cc19b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5TWC00GW0P3X02HZW', 'epic', NULL, 'UI tweaks & fixes', 'Ongoing umbrella for small, standalone UI polish, cosmetic tweaks, and visual/interaction bug fixes that don''t belong to a feature epic — color/token corrections, control placement, status surfaces, micro-interactions, and the wireframes that frame them. Children are independently shippable; the epic stays open as a rolling home for this class of work.
**PERMANENT — never close.** This is a standing rolling tracker for loose UI/UX work and bugs, not a deliverable epic. It stays open indefinitely; only its children are completed/closed. Do not mark T-276 done even when all current children are closed — new tweaks/fixes get filed here on an ongoing basis.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-08 07:46:23', '2026-06-10 12:08:14', NULL, '5897237a09dcf146461d532806e0a118', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5TWC00GW0P3X02HZW', 'epic', NULL, 'UI tweaks & fixes', 'Ongoing umbrella for small, standalone UI polish, cosmetic tweaks, and visual/interaction bug fixes that don''t belong to a feature epic — color/token corrections, control placement, status surfaces, micro-interactions, and the wireframes that frame them. Children are independently shippable; the epic stays open as a rolling home for this class of work.
**PERMANENT — never close.** This is a standing rolling tracker for loose UI/UX work and bugs, not a deliverable epic. It stays open indefinitely; only its children are completed/closed. Do not mark T-276 done even when all current children are closed — new tweaks/fixes get filed here on an ongoing basis.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-08 07:46:23', '2026-06-10 12:08:18', NULL, 'bb9d23c8e18d23c95ddbcdf7595f9b35', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2W4G9K8ZF782W7H2TM5XA8', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Hover ''pick up ticket'' run-icon that injects the full ticket into the focused Claude pane via the message bus', 'On ticket-card mouseover in the tickets sidebar, surface a small `person-simple-run` icon that, when clicked, hands the full ticket to the currently-focused Claude conversation panel as a "pick this up and start processing it" prompt. Routed over the message bus, not by reaching into the session orchestrator directly.
**Interaction**
- Hover a ticket card -> a `person-simple-run` action icon fades in (top-right of the card, where the green-arrow mock points).
- Click -> the focused Claude pane receives an injected user prompt that contains the full ticket (all fields, as the context/detail pane renders it) plus a short instruction to begin working it.
**Where (file:line)**
- Card + hover: `_TicketCard` in lib/builtin/tickets/src/tickets_view.dart:226-304. `ClideTappable` (line 242) already exposes a `hovered` bool in its builder (line 244) — gate the icon''s visibility on that. No new MouseRegion needed.
- Icon: `PhosphorIcons.byName(''person-simple-run'')` (lib/widgets/src/icons/phosphor.dart:49). Confirmed present in phosphor_glyphs.g.dart (0xe730).
- Full-ticket payload: fetch via the same path the context pane uses — `pql.tickets.show` with `withContext:true` (ticket_detail_controller.dart:45-49) -> TicketDetail (id/title/type/status/priority/description/parentId/decisionRef/assignedTo, ticket_detail_controller.dart:12-20). No serializer exists yet; build the prompt text from these fields (same content the detail view renders, ticket_detail_view.dart:59-127).
**Use the message bus (required)**
- Do NOT call ClaudeSessionOrchestrator.injectMessage directly from the sidebar. Publish on the bus instead, matching the existing tickets pattern: `messages.publish(''builtin.tickets'', ''<channel>'', {...})` (see ticket_detail_controller.dart:62 publishing ''focus''; bus lives on the kernel facade as `messages`, facade.dart:87/144).
- Proposed: publish `(''builtin.tickets'', ''pick-up'', { ''id'': T-NNN, ''prompt'': <full ticket + instruction text> })`.
- The Claude builtin subscribes to that channel and injects into the focused session — it resolves the focused pane via focus.activeContributionId (focus.dart:27-28) and routes through the orchestrator (session_orchestrator.dart:331-336 injectMessage / stream_json_session.dart:635 send). This keeps the sidebar decoupled from Claude internals and honors the bus-for-interaction rule.
**Edge cases (settle in review)**
- No focused Claude pane (focus.activeContributionId null / non-Claude pane focused): no-op with a toast, or fall back to ''primary''? Decide in review.
- Busy session (mid-turn): queue the inject vs. block with a hint.
- Prompt shape: confirm the exact wording/format of the injected message (full ticket markdown + a one-line "start processing this ticket" lead-in).
**Acceptance**
- Hovering a sidebar ticket card reveals a `person-simple-run` icon; it is hidden when not hovered.
- Clicking it publishes a single message-bus event carrying the full ticket; the focused Claude pane receives an injected prompt containing all ticket fields and an instruction to begin.
- The sidebar code does not import or call the session orchestrator directly — interaction is bus-only.
- Clicking with no focused Claude pane degrades gracefully (no crash).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 12:10:04', '2026-06-10 12:10:04', NULL, 'a23c4f546ae8408988106e0c98fcac03', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2W4G9K8ZF782W7H2TM5XA8', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Hover ''pick up ticket'' run-icon that injects the full ticket into the focused Claude pane via the message bus', 'On ticket-card mouseover in the tickets sidebar, surface a small `person-simple-run` icon that, when clicked, hands the full ticket to the currently-focused Claude conversation panel as a "pick this up and start processing it" prompt. Routed over the message bus, not by reaching into the session orchestrator directly.
**Interaction**
- Hover a ticket card -> a `person-simple-run` action icon fades in (top-right of the card, where the green-arrow mock points).
- Click -> the focused Claude pane receives an injected user prompt that contains the full ticket (all fields, as the context/detail pane renders it) plus a short instruction to begin working it.
**Where (file:line)**
- Card + hover: `_TicketCard` in lib/builtin/tickets/src/tickets_view.dart:226-304. `ClideTappable` (line 242) already exposes a `hovered` bool in its builder (line 244) — gate the icon''s visibility on that. No new MouseRegion needed.
- Icon: `PhosphorIcons.byName(''person-simple-run'')` (lib/widgets/src/icons/phosphor.dart:49). Confirmed present in phosphor_glyphs.g.dart (0xe730).
- Full-ticket payload: fetch via the same path the context pane uses — `pql.tickets.show` with `withContext:true` (ticket_detail_controller.dart:45-49) -> TicketDetail (id/title/type/status/priority/description/parentId/decisionRef/assignedTo, ticket_detail_controller.dart:12-20). No serializer exists yet; build the prompt text from these fields (same content the detail view renders, ticket_detail_view.dart:59-127).
**Use the message bus (required)**
- Do NOT call ClaudeSessionOrchestrator.injectMessage directly from the sidebar. Publish on the bus instead, matching the existing tickets pattern: `messages.publish(''builtin.tickets'', ''<channel>'', {...})` (see ticket_detail_controller.dart:62 publishing ''focus''; bus lives on the kernel facade as `messages`, facade.dart:87/144).
- Proposed: publish `(''builtin.tickets'', ''pick-up'', { ''id'': T-NNN, ''prompt'': <full ticket + instruction text> })`.
- The Claude builtin subscribes to that channel and injects into the focused session — it resolves the focused pane via focus.activeContributionId (focus.dart:27-28) and routes through the orchestrator (session_orchestrator.dart:331-336 injectMessage / stream_json_session.dart:635 send). This keeps the sidebar decoupled from Claude internals and honors the bus-for-interaction rule.
**Edge cases (settle in review)**
- No focused Claude pane (focus.activeContributionId null / non-Claude pane focused): no-op with a toast, or fall back to ''primary''? Decide in review.
- Busy session (mid-turn): queue the inject vs. block with a hint.
- Prompt shape: confirm the exact wording/format of the injected message (full ticket markdown + a one-line "start processing this ticket" lead-in).
**Acceptance**
- Hovering a sidebar ticket card reveals a `person-simple-run` icon; it is hidden when not hovered.
- Clicking it publishes a single message-bus event carrying the full ticket; the focused Claude pane receives an injected prompt containing all ticket fields and an instruction to begin.
- The sidebar code does not import or call the session orchestrator directly — interaction is bus-only.
- Clicking with no focused Claude pane degrades gracefully (no crash).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 12:10:04', '2026-06-10 12:12:30', NULL, '0847120e70ae275e3e115478d092c283', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1XDWKQ594ET4GDYEFK5ZJ4', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Permission card: add ''Deny & simplify'' option that denies with a retry-simpler note', 'Add a fourth option to the Claude permission prompt card (lib/builtin/claude/src/prompt_card.dart) alongside Allow / Allow & don''t ask again / Deny.
WHAT IT DOES
A deny that carries a preformatted follow-up note telling Claude the action was too complex for the permission system and to retry in a simpler format. Implemented as a _permDeny variant that passes a fixed note into DenyTool (today _permDeny uses _permNote() ?? ''Denied by the user.'' at prompt_card.dart:202). The user''s own note field, if filled, should still be respected — append it to / combine with the preformatted text rather than discard it.
LABEL: ''Deny & simplify'' (working label; placed after Deny).
TOOLTIP: ClideButton already supports (clide_button.dart:26) — add a mouseover explaining the behavior, e.g. ''Deny and ask Claude to retry this action in a simpler format — complex interactions don''t work well with the permission system.''
PREFORMATTED DENY NOTE (workshop wording, starting point):
"Denied — this action is too complex for the permission system to approve cleanly. Please retry with a simpler, more granular approach (break it into smaller steps or use a plainer command) to avoid this permission prompt. This is a one-off for THIS action only: do not add a memory and do not change permission settings — just reformulate and try again."
The ''do not add a memory / do not change permission settings'' clause is deliberate: without it Claude tends to ''fix'' the permission system (writing memories, rewriting permission config), which means continuous fiddling with a surface we don''t want it touching.
NUMBER-KEY SLOT
_activateNumber (prompt_card.dart:161-164) maps 1=Allow, 2=Allow&remember (when canRemember), 3/2=Deny. Add the new option as the next index (4 when canRemember, else 3). Keep Deny as its own option; the new one is additive. Update the digit/numpad shortcut mapping accordingly (see also T-310 numpad parity).
DESIGN NOTE — escalation behavior
The deny note enters the conversation and stays in context, so repeated use within one session compounds (Claude leans progressively harder toward simpler formats). That''s largely the intended escalating pressure, but the note is phrased as a one-shot ''retry THIS action'' rather than a standing rule to limit over-correction. Worth watching in testing whether repeated denials over-bias toward trivial formats.
ACCEPTANCE
- A fourth button ''Deny & simplify'' appears on the permission card with a tooltip.
- Activating it resolves the prompt as a deny whose note is the preformatted retry-simpler text (with the user''s typed note appended when present).
- The note explicitly tells Claude not to add a memory or change permission settings.
- Number-row and numpad digit shortcuts address the new option in the correct slot.
- Widget test covers the new button resolving to a DenyTool with the expected note.
CLARIFICATION (the TOOLTIP line above lost a word to shell escaping): ClideButton already exposes a tooltip parameter (clide_button.dart:17,26,42) — pass tooltip on the new button for the mouseover; no widget change needed.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 09:55:55', '2026-06-10 12:14:00', NULL, '995bc6dbbc7231306303b838aba8a2eb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1Q1Y3CYJHD7W5F68VDB6T4', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Surface Claude''s task list docked above the bottom input pane', 'Claude maintains a task/todo list during a session (the TodoWrite / TaskCreate checklist), but clide never shows it — it is completely invisible to the user. The transcript reader (lib/builtin/claude/src/transcript_reader.dart:205-212) skips the relevant event types and there is no model class for task items; the conversation view (conversation_view.dart) renders none. So the user has no view of what Claude is tracking or how far along a multi-step task is.
Surface it as a compact, always-current task list DOCKED to the bottom input pane of the Claude conversation view. Per the Explore: the pane is a Column in claude_pane.dart (_ClaudePaneState.build, ~line 474-542) with ConversationView (Expanded) on top and the ToolPromptCard/ClaudeComposer at the bottom (~line 519-525); the task list should sit between them — directly above the composer (claude_composer.dart), non-expanded — so it stays pinned and visible as Claude works.
Scope:
- PARSE: model Claude''s task list from the transcript. Find the event type carrying the todo/task items (currently among the skipped types in transcript_reader.dart) and add a TaskList model + items (text + status: pending/in_progress/completed). Latest-write-wins — the list reflects the most recent todo state, not an append log.
- RENDER: a docked task-list widget above the composer. Compact: collapsed/summary by default (e.g. ''N tasks · M done'' + current in_progress item), expandable to the full checklist. Per-item status glyph (pending/in-progress/done) using existing tokens + Phosphor icons (ui-design skill). Hide entirely when there are no tasks.
- Keep it display-only and de-emphasised so it never competes with the composer for attention; honour D-78 (interaction zone) — this is a display surface, not an interactive control.
- a11y: list semantics, status announced per item.
Acceptance:
1. When Claude has an active task list, it shows docked directly above the input pane and updates live as items change state.
2. Empty/no-task sessions show nothing (no empty chrome).
3. Collapsed by default with a one-line summary; expandable to the full list.
4. Display-only; the composer keeps full function and focus behaviour.
5. Tests: transcript_reader parses the task-list event into the model (incl. status transitions + latest-wins); widget renders states + collapse/expand + hidden-when-empty.
Refs: conversation card patterns (conversation_card.dart), D-78 (interaction zone), ui-design skill (tokens/icons).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 09:28:04', '2026-06-10 12:31:23', NULL, 'ddfb6e694a6c5b6e3bc39132cd57318c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1Q1Y3CYJHD7W5F68VDB6T4', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Surface Claude''s task list docked above the bottom input pane', 'Claude maintains a task/todo list during a session (the TodoWrite / TaskCreate checklist), but clide never shows it — it is completely invisible to the user. The transcript reader (lib/builtin/claude/src/transcript_reader.dart:205-212) skips the relevant event types and there is no model class for task items; the conversation view (conversation_view.dart) renders none. So the user has no view of what Claude is tracking or how far along a multi-step task is.
Surface it as a compact, always-current task list DOCKED to the bottom input pane of the Claude conversation view. Per the Explore: the pane is a Column in claude_pane.dart (_ClaudePaneState.build, ~line 474-542) with ConversationView (Expanded) on top and the ToolPromptCard/ClaudeComposer at the bottom (~line 519-525); the task list should sit between them — directly above the composer (claude_composer.dart), non-expanded — so it stays pinned and visible as Claude works.
Scope:
- PARSE: model Claude''s task list from the transcript. Find the event type carrying the todo/task items (currently among the skipped types in transcript_reader.dart) and add a TaskList model + items (text + status: pending/in_progress/completed). Latest-write-wins — the list reflects the most recent todo state, not an append log.
- RENDER: a docked task-list widget above the composer. Compact: collapsed/summary by default (e.g. ''N tasks · M done'' + current in_progress item), expandable to the full checklist. Per-item status glyph (pending/in-progress/done) using existing tokens + Phosphor icons (ui-design skill). Hide entirely when there are no tasks.
- Keep it display-only and de-emphasised so it never competes with the composer for attention; honour D-78 (interaction zone) — this is a display surface, not an interactive control.
- a11y: list semantics, status announced per item.
Acceptance:
1. When Claude has an active task list, it shows docked directly above the input pane and updates live as items change state.
2. Empty/no-task sessions show nothing (no empty chrome).
3. Collapsed by default with a one-line summary; expandable to the full list.
4. Display-only; the composer keeps full function and focus behaviour.
5. Tests: transcript_reader parses the task-list event into the model (incl. status transitions + latest-wins); widget renders states + collapse/expand + hidden-when-empty.
Refs: conversation card patterns (conversation_card.dart), D-78 (interaction zone), ui-design skill (tokens/icons).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 09:28:04', '2026-06-10 12:32:07', NULL, '9ed7b932a0448a813e89c462e383b88a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2W4G9K8ZF782W7H2TM5XA8', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Hover ''pick up ticket'' run-icon that injects the full ticket into the focused Claude pane via the message bus', 'On ticket-card mouseover in the tickets sidebar, surface a small `person-simple-run` icon that, when clicked, hands the full ticket to the currently-focused Claude conversation panel as a "pick this up and start processing it" prompt. Routed over the message bus, not by reaching into the session orchestrator directly.
**Interaction**
- Hover a ticket card -> a `person-simple-run` action icon fades in (top-right of the card, where the green-arrow mock points).
- Click -> the focused Claude pane receives an injected user prompt that contains the full ticket (all fields, as the context/detail pane renders it) plus a short instruction to begin working it.
**Where (file:line)**
- Card + hover: `_TicketCard` in lib/builtin/tickets/src/tickets_view.dart:226-304. `ClideTappable` (line 242) already exposes a `hovered` bool in its builder (line 244) — gate the icon''s visibility on that. No new MouseRegion needed.
- Icon: `PhosphorIcons.byName(''person-simple-run'')` (lib/widgets/src/icons/phosphor.dart:49). Confirmed present in phosphor_glyphs.g.dart (0xe730).
- Full-ticket payload: fetch via the same path the context pane uses — `pql.tickets.show` with `withContext:true` (ticket_detail_controller.dart:45-49) -> TicketDetail (id/title/type/status/priority/description/parentId/decisionRef/assignedTo, ticket_detail_controller.dart:12-20). No serializer exists yet; build the prompt text from these fields (same content the detail view renders, ticket_detail_view.dart:59-127).
**Use the message bus (required)**
- Do NOT call ClaudeSessionOrchestrator.injectMessage directly from the sidebar. Publish on the bus instead, matching the existing tickets pattern: `messages.publish(''builtin.tickets'', ''<channel>'', {...})` (see ticket_detail_controller.dart:62 publishing ''focus''; bus lives on the kernel facade as `messages`, facade.dart:87/144).
- Proposed: publish `(''builtin.tickets'', ''pick-up'', { ''id'': T-NNN, ''prompt'': <full ticket + instruction text> })`.
- The Claude builtin subscribes to that channel and injects into the focused session — it resolves the focused pane via focus.activeContributionId (focus.dart:27-28) and routes through the orchestrator (session_orchestrator.dart:331-336 injectMessage / stream_json_session.dart:635 send). This keeps the sidebar decoupled from Claude internals and honors the bus-for-interaction rule.
**Edge cases (settle in review)**
- No focused Claude pane (focus.activeContributionId null / non-Claude pane focused): no-op with a toast, or fall back to ''primary''? Decide in review.
- Busy session (mid-turn): queue the inject vs. block with a hint.
- Prompt shape: confirm the exact wording/format of the injected message (full ticket markdown + a one-line "start processing this ticket" lead-in).
**Acceptance**
- Hovering a sidebar ticket card reveals a `person-simple-run` icon; it is hidden when not hovered.
- Clicking it publishes a single message-bus event carrying the full ticket; the focused Claude pane receives an injected prompt containing all ticket fields and an instruction to begin.
- The sidebar code does not import or call the session orchestrator directly — interaction is bus-only.
- Clicking with no focused Claude pane degrades gracefully (no crash).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 12:10:04', '2026-06-10 12:51:50', NULL, 'a9982bdc3ed2e36cc57787e4582c4e46', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1S7613SYF0M9XQT5JNWM40', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Lightbox: click anywhere outside the image should dismiss', 'When the image lightbox is open, clicking outside the image should close it, matching Esc and the close (x) button.
Today only the thin margin around the lightbox dismisses. The DialogRouter host (lib/kernel/src/dialog.dart:95-103) renders a backdrop that dismisses on tap, but wraps the dialog content in a tap-swallowing GestureDetector (onTap: () {}). ClideLightbox fills a 94%-of-screen SizedBox whose Positioned.fill InteractiveViewer (lib/widgets/src/clide_lightbox.dart:93-113) covers the entire box. So taps on the dark area beside a smaller-than-94% image hit the swallower, not the backdrop, and nothing happens.
Fix: make taps that land outside the actual image bounds dismiss — e.g. add an onTap to the lightbox''s outer GestureDetector that calls onDismiss when the tap is not on the image (InteractiveViewer child), rather than relying on the host backdrop only reaching the 6% margin. Preserve double-tap-to-reset, scroll-to-zoom, and pan; a single tap on empty canvas closes, a tap/drag on the image does not.
Acceptance: single click on the dimmed area surrounding the image closes the lightbox; clicking/dragging/zooming the image does not; Esc and the x still close.', 'ready', 'low', NULL, NULL, NULL, '2026-06-10 09:37:31', '2026-06-10 12:56:38', NULL, '9c888af2ae0a8621ae428ac090508e0c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB37JZSFZKWPK9PDFYJY2YC0', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Deny & simplify note: don''t narrate the reformulation, just retry simpler', 'The T-311 ''Deny & simplify'' note prompts Claude to retry in a simpler form, but Claude tends to narrate the change (''right, splitting that command'', etc.) instead of silently retrying. Add a clause to the preformatted note (lib/builtin/claude/src/prompt_card.dart _kDenySimplifyNote) telling Claude NOT to narrate or re-explain the changes it makes — just proceed as it was, only simpler. Target: a quiet simpler retry, no commentary. Surfaced live while the button was tested against git commands.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-10 13:00:06', '2026-06-10 13:00:06', NULL, 'b6ed8a4e8b82ec89ca361032a3a0ba8c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB37JZSFZKWPK9PDFYJY2YC0', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Deny & simplify note: don''t narrate the reformulation, just retry simpler', 'The T-311 ''Deny & simplify'' note prompts Claude to retry in a simpler form, but Claude tends to narrate the change (''right, splitting that command'', etc.) instead of silently retrying. Add a clause to the preformatted note (lib/builtin/claude/src/prompt_card.dart _kDenySimplifyNote) telling Claude NOT to narrate or re-explain the changes it makes — just proceed as it was, only simpler. Target: a quiet simpler retry, no commentary. Surfaced live while the button was tested against git commands.', 'ready', 'low', NULL, NULL, NULL, '2026-06-10 13:00:06', '2026-06-10 13:00:19', NULL, '19e1551d324a36c8a2d737265847c448', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2TY91VHK7TPKPMZ11EG3TM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'ClideStatusIndicator: duplicate ''running'' key crashes AnimatedSwitcher on rapid status flips', 'AnimatedSwitcher in ClideStatusIndicator throws "Duplicate keys found" (Stack has multiple children with key [<''running''>]) during normal app run, cascading into a flood of follow-on errors ("Tried to build dirty widget in the wrong build scope", "debugNeedsLayout is not true", "ScrollController attached to multiple scroll views", etc).
Location: lib/widgets/src/clide_status_indicator.dart:37 (AnimatedSwitcher at build()).
Root cause: each status maps to a child with a fixed ValueKey (''running'' / ''success'' / ''error''). AnimatedSwitcher cross-fades the outgoing and incoming child inside a Stack for its 200ms duration. When the status flips back to a value whose previous child is still animating out (e.g. running -> success -> running within 200ms, or repeated running rebuilds), the still-exiting child and the new child both carry ValueKey(''running'') and collide in the Stack -> duplicate-key assertion. The downstream exceptions are the framework unwinding from the failed build.
Repro: observed live during `make run` with two Claude panes bound (primary + secondary-1); status indicators flipping quickly trigger it.
Fix direction: the ValueKey must be unique per indicator instance, not just per status, so two instances (or an in-flight transition) never share a key. Options: key by status combined with a stable per-widget id, or drop the const keys and let AnimatedSwitcher key on child type. Add a widget test that rapidly toggles status within the switch duration and pumps mid-transition to guard the regression.', 'done', 'high', NULL, NULL, NULL, '2026-06-10 12:04:51', '2026-06-10 13:00:29', NULL, '8e5bd637d25313c220c5565e54ee657f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1S7613SYF0M9XQT5JNWM40', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Lightbox: click anywhere outside the image should dismiss', 'When the image lightbox is open, clicking outside the image should close it, matching Esc and the close (x) button.
Today only the thin margin around the lightbox dismisses. The DialogRouter host (lib/kernel/src/dialog.dart:95-103) renders a backdrop that dismisses on tap, but wraps the dialog content in a tap-swallowing GestureDetector (onTap: () {}). ClideLightbox fills a 94%-of-screen SizedBox whose Positioned.fill InteractiveViewer (lib/widgets/src/clide_lightbox.dart:93-113) covers the entire box. So taps on the dark area beside a smaller-than-94% image hit the swallower, not the backdrop, and nothing happens.
Fix: make taps that land outside the actual image bounds dismiss — e.g. add an onTap to the lightbox''s outer GestureDetector that calls onDismiss when the tap is not on the image (InteractiveViewer child), rather than relying on the host backdrop only reaching the 6% margin. Preserve double-tap-to-reset, scroll-to-zoom, and pan; a single tap on empty canvas closes, a tap/drag on the image does not.
Acceptance: single click on the dimmed area surrounding the image closes the lightbox; clicking/dragging/zooming the image does not; Esc and the x still close.', 'done', 'low', NULL, NULL, NULL, '2026-06-10 09:37:31', '2026-06-10 13:15:15', NULL, '2c16b97db9098d73e122bfbaba2b74eb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB37JZSFZKWPK9PDFYJY2YC0', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Deny & simplify note: don''t narrate the reformulation, just retry simpler', 'The T-311 ''Deny & simplify'' note prompts Claude to retry in a simpler form, but Claude tends to narrate the change (''right, splitting that command'', etc.) instead of silently retrying. Add a clause to the preformatted note (lib/builtin/claude/src/prompt_card.dart _kDenySimplifyNote) telling Claude NOT to narrate or re-explain the changes it makes — just proceed as it was, only simpler. Target: a quiet simpler retry, no commentary. Surfaced live while the button was tested against git commands.', 'done', 'low', NULL, NULL, NULL, '2026-06-10 13:00:06', '2026-06-10 13:18:57', NULL, 'd63c15b0903f860a85dd1b437e96c388', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7F2TBJJV1F2KP7XR8', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Clickable file references in Claude conversation open in editor', 'Make file-path references rendered in the Claude conversation pane clickable: clicking a path opens that file in the editor.
**Behavior**
- Detect references to files in the workspace as the conversation markdown is rendered — bare paths (`lib/app.dart`), `file:line` forms (`lib/app.dart:42`, clickable per CLAUDE.md), and likely inline-code spans / markdown links pointing at repo paths.
- Resolve against the workspace root (CLIDE_WORKSPACE / git repo root). Only linkify paths that exist in the repo to avoid false positives on prose.
- On click, open in the editor — same path as `clide editor open <path>` — and jump to the line when a `:line` suffix is present.
**Notes / constraints**
- Markdown rendering is clide-owned custom CustomPaint/widgets (not a package), so detection + hit-testing lands in the conversation/markdown render path.
- Honors User/Claude parity (D-6): the click maps to the existing `clide editor open` verb.
- Open questions to settle during design: how aggressive path detection should be (existence check vs. heuristic), handling of non-existent / external paths, and visual affordance (underline/hover) for a linkified ref.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-09 20:02:41', '2026-06-10 13:24:15', NULL, '75c7f89b46b83a29e547f7efe3314826', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DHCTP001YCHFP39XER0ZM', 'epic', NULL, 'SSH-remote workspaces', 'Run clide locally but open and work in a git repo that lives on a remote machine over SSH. All shell actions (git, pql, the claude process, terminal panes, file I/O) execute remotely; only inherently-local things (clipboard / image paste) stay local and bridge their bytes across. Resolves Q-23. The user''s original ''pql binary local, db+repo remote'' hypothesis is not viable (pql reads its .pql/ index off the filesystem, git needs .git/ local, inotify does not cross sshfs) — so the cut is: execution is remote, UI + clipboard stay local, bridged by SSH. Load-bearing tension: execution must be remote, but the user does not want to install clide components on the remote or manage versions there — the footprint model is deferred to a Phase-0 spike. Locked: auth reuses system ssh + ~/.ssh/config for v1 (Windows is a known gap); v1 scope is full backend remote (files, git, pql, terminal, native Claude pane). Plan: ~/.claude/plans/can-we-plan-an-sleepy-gem.md', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 13:26:06', '2026-06-10 13:26:06', NULL, '28d37835a16accafb3eab563ad4003e3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DJZDDZ00BSA04B660RS7M', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Phase 0 — governance spike: pick remote-execution footprint model (resolve Q-23)', 'GATE for the whole epic — nothing else starts until this lands. Turn Q-23 into D-records and pick the footprint model with evidence. Decide between: (1) No-install ssh-exec (zero remote footprint) — stock ssh: ssh -tt PTYs for terminal/Claude, multiplexed ControlMaster command channels for git/pql/file ops; nothing clide-specific on the remote; watching degrades to polling; no stateful remote process. (2) Auto-pushed self-managed agent (VS Code Remote model) — clide auto-deploys + version-checks one self-contained binary on connect (a headless UI-less deployment of the existing subsystem library: lib/src/daemon, git, pql, files, pty, ipc/server.dart — hosting the same DaemonDispatcher + IpcServer); gains native inotify watching + stateful backend. MUST answer the user''s agent-model sub-questions: is it a proxy/backend? cleanup/GC on disconnect/version-bump/repo-removal? placement (per-repo .clide/agent vs per-host ~/.clide/agent vs shared — per-host shared likely)? multi-client sharing (two local clides / two users — share one agent or one each? IpcServer is already multi-connection, D-72)? version skew (version encoded in binary name, e.g. clide-agent-<ver>, so versions coexist)? Also measure ControlMaster per-command latency for the ssh-exec model. Also decide the remote-tool contract: what must exist remotely (git/pql/claude/shell), whether pql is hard-required or degrades, and how a preflight surfaces what is missing. Include the D-56 reconciliation: ''single process'' is scoped per-host-per-workspace; a headless remote deployment of the subsystem library does not violate the no-second-local-process rule. Artifacts: Q-23 -> Resolved; new D-records for footprint model + D-56 framing, ssh:// URI scheme, remote auth (system ssh, v1, Windows deferred), remote-tool contract, session identity keyed on (host, repo) amending D-41/D-77.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 13:26:19', '2026-06-10 13:26:19', NULL, '2ab1c13b28a4bd6a78b9e7aad88ab438', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DKQQJ583944DG8561VQ3G', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Phase 1 — backend transport seam (DaemonTransport, prove locally)', 'Model-independent backbone. Make the local app talk to its backend through a DaemonTransport abstraction with ZERO behavior change, so a remote transport can slot in later. New lib/src/ipc/transport.dart: DaemonTransport interface + LocalTransport (wraps today''s unix-socket connect). DaemonClient takes a transport instead of a hard-coded socket path; its reconnect loop generalizes to ''re-establish via transport''. lib/main.dart: swapIpcServer (~:119) becomes swapBackend — local binds the in-process IpcServer as today; seam ready for a remote branch. Wire protocol unchanged — JSON-lines IpcRequest/IpcResponse/IpcEvent (envelope.dart) already serialize over IPC; event stream, tail --events, events --since cursor-pull ride the same stream untouched. Verify: make test-core (IPC) + testmode skill — local path behaves identically through the seam, no regression. Depends on Phase 0 (T-330).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 13:26:25', '2026-06-10 13:26:25', NULL, '8aa11d9791af3d5653cf33260ed7b1ae', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Phase 2 — remote workspace identity + ssh:// open scheme', 'Model-independent backbone. clide can represent and open an ssh:// workspace end-to-end (actual remote calls land on the execution layer from the fork phase). URI: ssh://[user@]host[:port]/abs/remote/path, resolving host aliases via ~/.ssh/config. lib/kernel/src/project.dart: RecentProject gains host/port/user (absent = local; back-compatible toJson/fromJson), bool get isRemote, remote display form in relativePath/timeAgo (e.g. buildbox:~/repo); introduce a WorkspaceRef { String? host; String path; } value type and migrate _current/current off bare Directory (Directory(remotePath) is meaningless locally) — local callers read .path; resolveProject (~:204) branches: local runs git rev-parse as today, remote resolves the toplevel via the execution layer. lib/main.dart: swapBackend remote branch wires DaemonClient to the remote transport (no local server bound for remote workspaces). Connection lifecycle: connect -> resolve auth -> establish transport -> preflight remote tools -> ProjectOpened. On SSH drop, DaemonClient reconnect re-attaches; events --since cursor-pull (server.dart ~:339) is the re-sync primitive (gap:true -> UI full refresh). Verify: unit tests on RecentProject/WorkspaceRef JSON round-trips (local + remote); open ssh://localhost/... loopback workspace and confirm resolveProject returns the remote toplevel. Depends on Phase 1 (transport seam).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 13:26:31', '2026-06-10 13:26:31', NULL, 'a75b65a2775abc2852b81c2f1a078847', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DN94MBCTYJW17ZCYVSXE0', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Phase 3 — Claude remoting (stdio control channel)', 'Model-independent backbone. Native Claude pane works against a remote workspace. claude is spawned where the repo is (remote), workingDirectory = remote repo; its --resume transcripts live on the remote under ~/.claude/... — correct and automatic. New long-lived IPC verb claude.control.attach (modeled on the tail --events long-lived connection, server.dart ~:302) relays the stdio control protocol both directions, so canUseTool / AskUserQuestion prompts render natively locally and answers return over the tunnel (D-77/D-78). lib/builtin/claude/src/session_naming.dart: re-key the session seed on (host, repo) so the same path on two hosts (or local) is two sessions (D-41/D-77 amendment from Phase 0). lib/builtin/claude/src/agent_bootstrap.dart: CLIDE_SOCK/CLIDE_WORKSPACE/PATH describe the remote socket + workspace (computed remote-side). Verify: drive a Claude turn against the loopback-remote workspace; confirm a permission prompt renders natively and the answer flows back; confirm --resume re-attaches the remote transcript. Depends on Phase 2 + the execution-layer fork.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 13:26:38', '2026-06-10 13:26:38', NULL, 'b961853eda518a7508558d5c8d85fc87', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DNQZKV20F7YG5PJH8V8SM', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Phase 4 — image-paste local->remote bridge', 'Model-independent backbone. Paste an image from the local clipboard, reference it from remote Claude. Clipboard read stays local (clipboard_paste.dart, clide/clipboard MethodChannel) — clipboard is inherently local. New IPC verb files.put (write bytes remote-side) in lib/src/daemon/files_commands.dart — the only local->remote byte push. After writing the local temp image, push bytes to a remote scratch dir and rewrite ComposerAttachment.path to the remote path before building the @<path> token, so remote claude resolves it. New minor D-record: scratch-dir location (<repo>/.clide/pasted vs remote cache), tying into ignore discipline (D-4; .clide/ is already ignore-handled). Verify: paste a screenshot locally, confirm bytes land remotely and remote Claude reads the @path. Depends on Phase 2 + execution-layer fork.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 13:26:42', '2026-06-10 13:26:42', NULL, '85987f1719a16078b095a9d9ba8c8e14', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DP48FS33CQGRDF7EB9GT0', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Phase 5 — UI: remote connect, switcher, status indicator', 'Model-independent backbone. First-class remote affordances. lib/builtin/welcome/src/welcome_view.dart: ''Connect to remote...'' beside ''Open folder...''; remote recents render with a host badge. lib/app.dart: project switcher accepts remote refs (open() branches on isRemote); the existing daemon-indicator slot (~:1172) becomes the connection indicator (connected / reconnecting / offline). registerStatusCommand (main.dart ~:245): surface host in clide status. Verify: make test (analyze + format + unit + widget + golden) for the UI; manual: connect indicator reflects drop/reconnect. Depends on Phase 2 (open scheme). UI dresses a working backend, so land last.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 13:26:45', '2026-06-10 13:26:45', NULL, '65c981fa45c4cb52fbe7df59d8046c78', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Fork — execution layer (expand after Phase-0 spike decides footprint)', 'PLACEHOLDER — exact tickets crystallize after the Phase-0 spike (T-330) picks the footprint model. Both designs below so the choice is a swap, not a redesign. IF auto-pushed agent: execution layer is mostly transport + provisioning — subsystems (GitClient, PqlClient, FilesService, SearchService, NativePty) run UNCHANGED inside the remote agent; buildDispatcher (main.dart ~:176) split into UI-coupled vs headless-safe registrations; new bin/clide_agent.dart hosts the headless set + an IpcServer; RemoteTransport = SSH-tunneled agent socket (stdio bridge); watching is native inotify in the agent (watcher.dart unchanged); plus a provisioning module lib/src/remote/ (push, version-name, launch, GC). IF no-install ssh-exec: introduce a RemoteExecutionContext that each subsystem uses instead of bare Process.run/File/Directory — ssh -tt for PTYs, multiplexed ControlMaster command channels for git/pql/file ops, and a polling watcher (debounced git-status/mtime, or inotifywait if present) emitting the same FileChange events so the UI is unaware; heavier subsystem surface, zero remote footprint. Either way: pql runs where its .pql/ index lives (remote), git runs where .git/ lives (remote), clide still only wraps pql (D-3 preserved). Blocks Phases 3 and 4. Depends on Phase 0 (T-330) and Phase 1 (T-331).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 13:26:56', '2026-06-10 13:26:56', NULL, 'f5c3f4f28b4b4b6c4301ad0400182aee', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'ExitPlanMode does not exit plan mode in the conversation panel', 'Observed live: Claude used the ExitPlanMode tool (approved the plan / left plan mode), but the clide conversation panel did not reflect the exit — it still appears to be in plan mode afterward.
Expected: once ExitPlanMode is accepted, the pane leaves plan mode — the permission-mode indicator (claude_status / permission_mode_control) flips off ''plan'', and the composer / status reflect the new mode.
Where to look:
- How the stream-json session handles the ExitPlanMode tool/control event (stream_json_session.dart) and whether it updates the tracked permission mode (claude_status.dart permissionMode).
- The permission-mode control + status badge wiring (permission_mode_control.dart, claude_pane.dart) — does an ExitPlanMode resolution feed back into the mode the way Ctrl/Cmd+M and the picker do?
- Whether ExitPlanMode arrives as a permission prompt (ToolPromptCard) and, if approved, actually transitions the session out of plan mode rather than just resolving the tool.
Repro: enter plan mode, let Claude present a plan via ExitPlanMode, accept it — the pane stays showing plan mode.
Acceptance: accepting ExitPlanMode leaves plan mode in the conversation panel (mode indicator + behavior update), matching what the underlying session does.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 13:27:36', '2026-06-10 13:27:36', NULL, 'e4644bb33acffeb4ac1cdd2ab7dd0942', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7F2TBJJV1F2KP7XR8', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Clickable file references in Claude conversation open in editor', 'Make file-path references rendered in the Claude conversation pane clickable: clicking a path opens that file in the editor.
**Behavior**
- Detect references to files in the workspace as the conversation markdown is rendered — bare paths (`lib/app.dart`), `file:line` forms (`lib/app.dart:42`, clickable per CLAUDE.md), and likely inline-code spans / markdown links pointing at repo paths.
- Resolve against the workspace root (CLIDE_WORKSPACE / git repo root). Only linkify paths that exist in the repo to avoid false positives on prose.
- On click, open in the editor — same path as `clide editor open <path>` — and jump to the line when a `:line` suffix is present.
**Notes / constraints**
- Markdown rendering is clide-owned custom CustomPaint/widgets (not a package), so detection + hit-testing lands in the conversation/markdown render path.
- Honors User/Claude parity (D-6): the click maps to the existing `clide editor open` verb.
- Open questions to settle during design: how aggressive path detection should be (existence check vs. heuristic), handling of non-existent / external paths, and visual affordance (underline/hover) for a linkified ref.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-09 20:02:41', '2026-06-10 13:41:17', NULL, '36eeacc93f291c6db429b1fa7a75c8c4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JAXDKZMS0805MMEYB6820', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Agent prompt renders as a ''you'' card instead of folding into the Activity Agent card', 'When Claude spawns a sub-agent (Agent/Task tool, rendered as an ''Activity Agent'' card), the prompt handed to the sub-agent renders as a separate top-level UserMessage card labelled ''you'' (blue, globalFocus accent) above the card, instead of folding into the Activity Agent card as a muted ''agent prompt'' (the T-263 behaviour).
Repro: launch any sub-agent from the Claude pane; the task prompt appears as a ''you'' turn.
Root cause: the sidechain fold keys entirely off the transcript envelope. In transcript_reader.dart the prompt''s UserMessage carries isSidechain/parentUuid straight from envelope[''isSidechain''] and envelope[''parentUuid''] (transcript_reader.dart:558-560, parsed in _parseUserInto at :593-643). conversation_view.dart _sidechainFold() (:179-233) only claims an item when it.isSidechain is true AND resolveOwner() walks its parentUuid chain up to the Agent tool_use message (or the nearest preceding Agent as fallback). If the spawning prompt''s envelope arrives WITHOUT isSidechain==true (or without a parentUuid that roots at the Agent), the item is never added to ownedSidechainUuids, _visibleItems() (:116-160) never drops it, and _ConversationTurn.build() falls through past the ''when i.injected || i.isSidechain'' guard into the default UserMessage() case => label: ''you'' (the blue user card).
Need to confirm whether the prompt envelope is missing isSidechain (likely — the spawning prompt is the FIRST user turn of the sidechain and may be emitted by the harness without the flag, or with a parentUuid that points at the spawning turn rather than the Agent tool_use). Check stream_json_session.dart emission too.
Fix direction (TBD after confirming the envelope shape): either (a) ensure the prompt envelope is correctly flagged/parented, or (b) in _sidechainFold associate the first sidechain-adjacent user prompt with its Agent tool_use even when the flag/chain is incomplete. Related: T-263 (prompt folds into the call), T-264 (run nests under the card).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 13:47:04', '2026-06-10 13:47:04', NULL, '564de005ff82db2137a7fc173038dd46', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JM0AXK1CTWD720RV1AVZ0', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Pick-up sets ticket to in_progress on accept', 'When a ticket is handed to Claude via the sidebar Pick-up button (T-327), also transition the ticket to in_progress — but on the receiving side of the message bus, not in the button.
Today the Pick-up button (`_PickUpAction` in lib/builtin/tickets/src/tickets_view.dart) only fetches the ticket and publishes a (builtin.tickets, pick-up) message carrying {id, prompt}. The Claude pane receives it in `_onTicketPickUp` (lib/builtin/claude/src/extension.dart:359) and injects the prompt into the active session. The state transition should live here, gated on acceptance — i.e. only set in_progress once a live session actually accepts the prompt (injectMessage succeeds against a target session), so a pick-up with no live Claude pane stays a quiet no-op and does not mutate state.
Implementation notes:
- In `_onTicketPickUp`, after a successful injectMessage to a target session, call the existing status transition path: IPC `pql.tickets.status` with {ids: [id], status: ''in_progress''} (same call the detail view''s _StatusControls uses).
- The ''id'' is already in the message payload — currently only ''prompt'' is read; also read ''id''.
- After a successful transition, publish (builtin.tickets, changed, {id}) so the sidebar/detail refresh, matching ticket_detail_view.dart behaviour.
- Keep it idempotent/safe: if the ticket is already in_progress (or terminal), don''t fight the user — consider skipping when current status != open/todo. Verify the configured status vocabulary via `pql ticket statuslist`.
- No live session => no injection => no state change (preserve existing no-op semantics).
Acceptance:
- Picking up a ticket with a live Claude pane injects the prompt AND moves the ticket to in_progress, with the sidebar reflecting the new state.
- Picking up with no live session leaves the ticket untouched.
- Test coverage on _onTicketPickUp for both the accepted and no-session paths.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 13:48:18', '2026-06-10 13:48:18', NULL, '33c0a8b0a26f1012dffc3dfa1e742463', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3KS499THAD899M0NWN7E3R', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Fold the Deny & simplify denial note instead of showing it as an expanded error', 'The ''Deny & simplify'' permission option (T-311/T-328) denies the action carrying a preformatted note (_kDenySimplifyNote in lib/builtin/claude/src/prompt_card.dart:27). Because it comes back as a tool_result with isError, the Claude pane renders it as a prominent red ''Bash · error'' block, expanded by default — see _toolResult() in lib/builtin/claude/src/conversation_view.dart:698, specifically collapsedByDefault: false (line 715).
But this denial is user-initiated (the user clicked ''Deny & simplify''), so the full red confirmation is noise — the user already knows what they did and just wants Claude to retry simpler. Either suppress this specific error block, or fold it into the collapsible (collapsedByDefault: true, with the first line as the collapsed summary) so it doesn''t shout.
Scope: ONLY the Deny & simplify-spawned error. Genuine Bash/tool errors must keep their current expanded-by-default behaviour (T-168). So this needs a way to distinguish a user-initiated deny-simplify denial from a real tool error — e.g. tag the DenyTool result so the renderer can recognise it, rather than string-matching the note text.
Implementation sketch:
- Mark the deny-simplify result as a user-initiated denial when constructing DenyTool(note) in _permDenySimplify() (prompt_card.dart:234), carrying a flag through the tool_result envelope.
- In _toolResult(), when that flag is set, render collapsed-by-default (or suppress) instead of the expanded error path.
- Avoid brittle string matching against _kDenySimplifyNote.
Acceptance:
- Clicking ''Deny & simplify'' produces a collapsed/quiet card, not an expanded red error.
- A real Bash error still renders expanded by default.
Reference screenshot: paste-1781099488608.png (the expanded red ''Denied — this action is too complex...'' block over a follow-up retry).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 13:53:23', '2026-06-10 13:53:23', NULL, 'b00b71e6716b515b5765abe808923132', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JAXDKZMS0805MMEYB6820', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Agent prompt renders as a ''you'' card instead of folding into the Activity Agent card', 'When Claude spawns a sub-agent (Agent/Task tool, rendered as an ''Activity Agent'' card), the prompt handed to the sub-agent renders as a separate top-level UserMessage card labelled ''you'' (blue, globalFocus accent) above the card, instead of folding into the Activity Agent card as a muted ''agent prompt'' (the T-263 behaviour).
Repro: launch any sub-agent from the Claude pane; the task prompt appears as a ''you'' turn.
Root cause: the sidechain fold keys entirely off the transcript envelope. In transcript_reader.dart the prompt''s UserMessage carries isSidechain/parentUuid straight from envelope[''isSidechain''] and envelope[''parentUuid''] (transcript_reader.dart:558-560, parsed in _parseUserInto at :593-643). conversation_view.dart _sidechainFold() (:179-233) only claims an item when it.isSidechain is true AND resolveOwner() walks its parentUuid chain up to the Agent tool_use message (or the nearest preceding Agent as fallback). If the spawning prompt''s envelope arrives WITHOUT isSidechain==true (or without a parentUuid that roots at the Agent), the item is never added to ownedSidechainUuids, _visibleItems() (:116-160) never drops it, and _ConversationTurn.build() falls through past the ''when i.injected || i.isSidechain'' guard into the default UserMessage() case => label: ''you'' (the blue user card).
Need to confirm whether the prompt envelope is missing isSidechain (likely — the spawning prompt is the FIRST user turn of the sidechain and may be emitted by the harness without the flag, or with a parentUuid that points at the spawning turn rather than the Agent tool_use). Check stream_json_session.dart emission too.
Fix direction (TBD after confirming the envelope shape): either (a) ensure the prompt envelope is correctly flagged/parented, or (b) in _sidechainFold associate the first sidechain-adjacent user prompt with its Agent tool_use even when the flag/chain is incomplete. Related: T-263 (prompt folds into the call), T-264 (run nests under the card).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 13:47:04', '2026-06-10 13:53:28', NULL, 'bd4b80fac2b7afc66056ca93d3281736', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3KS499THAD899M0NWN7E3R', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Fold the Deny & simplify denial note instead of showing it as an expanded error', 'The ''Deny & simplify'' permission option (T-311/T-328) denies the action carrying a preformatted note (_kDenySimplifyNote in lib/builtin/claude/src/prompt_card.dart:27). Because it comes back as a tool_result with isError, the Claude pane renders it as a prominent red ''Bash · error'' block, expanded by default — see _toolResult() in lib/builtin/claude/src/conversation_view.dart:698, specifically collapsedByDefault: false (line 715).
But this denial is user-initiated (the user clicked ''Deny & simplify''), so the full red confirmation is noise — the user already knows what they did and just wants Claude to retry simpler. Either suppress this specific error block, or fold it into the collapsible (collapsedByDefault: true, with the first line as the collapsed summary) so it doesn''t shout.
Scope: ONLY the Deny & simplify-spawned error. Genuine Bash/tool errors must keep their current expanded-by-default behaviour (T-168). So this needs a way to distinguish a user-initiated deny-simplify denial from a real tool error — e.g. tag the DenyTool result so the renderer can recognise it, rather than string-matching the note text.
Implementation sketch:
- Mark the deny-simplify result as a user-initiated denial when constructing DenyTool(note) in _permDenySimplify() (prompt_card.dart:234), carrying a flag through the tool_result envelope.
- In _toolResult(), when that flag is set, render collapsed-by-default (or suppress) instead of the expanded error path.
- Avoid brittle string matching against _kDenySimplifyNote.
Acceptance:
- Clicking ''Deny & simplify'' produces a collapsed/quiet card, not an expanded red error.
- A real Bash error still renders expanded by default.
Reference screenshot: paste-1781099488608.png (the expanded red ''Denied — this action is too complex...'' block over a follow-up retry).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 13:53:23', '2026-06-10 13:53:55', NULL, '11e5f8e73fda443dfd532cbb20219b52', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JM0AXK1CTWD720RV1AVZ0', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Pick-up sets ticket to in_progress on accept', 'When a ticket is handed to Claude via the sidebar Pick-up button (T-327), also transition the ticket to in_progress — but on the receiving side of the message bus, not in the button.
Today the Pick-up button (`_PickUpAction` in lib/builtin/tickets/src/tickets_view.dart) only fetches the ticket and publishes a (builtin.tickets, pick-up) message carrying {id, prompt}. The Claude pane receives it in `_onTicketPickUp` (lib/builtin/claude/src/extension.dart:359) and injects the prompt into the active session. The state transition should live here, gated on acceptance — i.e. only set in_progress once a live session actually accepts the prompt (injectMessage succeeds against a target session), so a pick-up with no live Claude pane stays a quiet no-op and does not mutate state.
Implementation notes:
- In `_onTicketPickUp`, after a successful injectMessage to a target session, call the existing status transition path: IPC `pql.tickets.status` with {ids: [id], status: ''in_progress''} (same call the detail view''s _StatusControls uses).
- The ''id'' is already in the message payload — currently only ''prompt'' is read; also read ''id''.
- After a successful transition, publish (builtin.tickets, changed, {id}) so the sidebar/detail refresh, matching ticket_detail_view.dart behaviour.
- Keep it idempotent/safe: if the ticket is already in_progress (or terminal), don''t fight the user — consider skipping when current status != open/todo. Verify the configured status vocabulary via `pql ticket statuslist`.
- No live session => no injection => no state change (preserve existing no-op semantics).
Acceptance:
- Picking up a ticket with a live Claude pane injects the prompt AND moves the ticket to in_progress, with the sidebar reflecting the new state.
- Picking up with no live session leaves the ticket untouched.
- Test coverage on _onTicketPickUp for both the accepted and no-session paths.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 13:48:18', '2026-06-10 13:54:02', NULL, 'a8639457defad7f7e958c9fd0c567d97', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7F2TBJJV1F2KP7XR8', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Clickable file references in Claude conversation open in editor', 'Make file-path references rendered in the Claude conversation pane clickable: clicking a path opens that file in the editor.
**Behavior**
- Detect references to files in the workspace as the conversation markdown is rendered — bare paths (`lib/app.dart`), `file:line` forms (`lib/app.dart:42`, clickable per CLAUDE.md), and likely inline-code spans / markdown links pointing at repo paths.
- Resolve against the workspace root (CLIDE_WORKSPACE / git repo root). Only linkify paths that exist in the repo to avoid false positives on prose.
- On click, open in the editor — same path as `clide editor open <path>` — and jump to the line when a `:line` suffix is present.
**Notes / constraints**
- Markdown rendering is clide-owned custom CustomPaint/widgets (not a package), so detection + hit-testing lands in the conversation/markdown render path.
- Honors User/Claude parity (D-6): the click maps to the existing `clide editor open` verb.
- Open questions to settle during design: how aggressive path detection should be (existence check vs. heuristic), handling of non-existent / external paths, and visual affordance (underline/hover) for a linkified ref.', 'done', 'medium', NULL, NULL, NULL, '2026-06-09 20:02:41', '2026-06-10 13:55:30', NULL, 'd4022c088022495e2a0bda625a45c636', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JAXDKZMS0805MMEYB6820', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Agent prompt renders as a ''you'' card instead of folding into the Activity Agent card', 'When Claude spawns a sub-agent (Agent/Task tool, rendered as an ''Activity Agent'' card), the prompt handed to the sub-agent renders as a separate top-level UserMessage card labelled ''you'' (blue, globalFocus accent) above the card, instead of folding into the Activity Agent card as a muted ''agent prompt'' (the T-263 behaviour).
Repro: launch any sub-agent from the Claude pane; the task prompt appears as a ''you'' turn.
Root cause: the sidechain fold keys entirely off the transcript envelope. In transcript_reader.dart the prompt''s UserMessage carries isSidechain/parentUuid straight from envelope[''isSidechain''] and envelope[''parentUuid''] (transcript_reader.dart:558-560, parsed in _parseUserInto at :593-643). conversation_view.dart _sidechainFold() (:179-233) only claims an item when it.isSidechain is true AND resolveOwner() walks its parentUuid chain up to the Agent tool_use message (or the nearest preceding Agent as fallback). If the spawning prompt''s envelope arrives WITHOUT isSidechain==true (or without a parentUuid that roots at the Agent), the item is never added to ownedSidechainUuids, _visibleItems() (:116-160) never drops it, and _ConversationTurn.build() falls through past the ''when i.injected || i.isSidechain'' guard into the default UserMessage() case => label: ''you'' (the blue user card).
Need to confirm whether the prompt envelope is missing isSidechain (likely — the spawning prompt is the FIRST user turn of the sidechain and may be emitted by the harness without the flag, or with a parentUuid that points at the spawning turn rather than the Agent tool_use). Check stream_json_session.dart emission too.
Fix direction (TBD after confirming the envelope shape): either (a) ensure the prompt envelope is correctly flagged/parented, or (b) in _sidechainFold associate the first sidechain-adjacent user prompt with its Agent tool_use even when the flag/chain is incomplete. Related: T-263 (prompt folds into the call), T-264 (run nests under the card).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 13:47:04', '2026-06-10 14:03:10', NULL, 'ee474dfe790eb6e72bd6a7381cd900e2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JM0AXK1CTWD720RV1AVZ0', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Pick-up sets ticket to in_progress on accept', 'When a ticket is handed to Claude via the sidebar Pick-up button (T-327), also transition the ticket to in_progress — but on the receiving side of the message bus, not in the button.
Today the Pick-up button (`_PickUpAction` in lib/builtin/tickets/src/tickets_view.dart) only fetches the ticket and publishes a (builtin.tickets, pick-up) message carrying {id, prompt}. The Claude pane receives it in `_onTicketPickUp` (lib/builtin/claude/src/extension.dart:359) and injects the prompt into the active session. The state transition should live here, gated on acceptance — i.e. only set in_progress once a live session actually accepts the prompt (injectMessage succeeds against a target session), so a pick-up with no live Claude pane stays a quiet no-op and does not mutate state.
Implementation notes:
- In `_onTicketPickUp`, after a successful injectMessage to a target session, call the existing status transition path: IPC `pql.tickets.status` with {ids: [id], status: ''in_progress''} (same call the detail view''s _StatusControls uses).
- The ''id'' is already in the message payload — currently only ''prompt'' is read; also read ''id''.
- After a successful transition, publish (builtin.tickets, changed, {id}) so the sidebar/detail refresh, matching ticket_detail_view.dart behaviour.
- Keep it idempotent/safe: if the ticket is already in_progress (or terminal), don''t fight the user — consider skipping when current status != open/todo. Verify the configured status vocabulary via `pql ticket statuslist`.
- No live session => no injection => no state change (preserve existing no-op semantics).
Acceptance:
- Picking up a ticket with a live Claude pane injects the prompt AND moves the ticket to in_progress, with the sidebar reflecting the new state.
- Picking up with no live session leaves the ticket untouched.
- Test coverage on _onTicketPickUp for both the accepted and no-session paths.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 13:48:18', '2026-06-10 14:14:33', NULL, 'd615d07653f4916b50514993935b90da', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3KS499THAD899M0NWN7E3R', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Fold the Deny & simplify denial note instead of showing it as an expanded error', 'The ''Deny & simplify'' permission option (T-311/T-328) denies the action carrying a preformatted note (_kDenySimplifyNote in lib/builtin/claude/src/prompt_card.dart:27). Because it comes back as a tool_result with isError, the Claude pane renders it as a prominent red ''Bash · error'' block, expanded by default — see _toolResult() in lib/builtin/claude/src/conversation_view.dart:698, specifically collapsedByDefault: false (line 715).
But this denial is user-initiated (the user clicked ''Deny & simplify''), so the full red confirmation is noise — the user already knows what they did and just wants Claude to retry simpler. Either suppress this specific error block, or fold it into the collapsible (collapsedByDefault: true, with the first line as the collapsed summary) so it doesn''t shout.
Scope: ONLY the Deny & simplify-spawned error. Genuine Bash/tool errors must keep their current expanded-by-default behaviour (T-168). So this needs a way to distinguish a user-initiated deny-simplify denial from a real tool error — e.g. tag the DenyTool result so the renderer can recognise it, rather than string-matching the note text.
Implementation sketch:
- Mark the deny-simplify result as a user-initiated denial when constructing DenyTool(note) in _permDenySimplify() (prompt_card.dart:234), carrying a flag through the tool_result envelope.
- In _toolResult(), when that flag is set, render collapsed-by-default (or suppress) instead of the expanded error path.
- Avoid brittle string matching against _kDenySimplifyNote.
Acceptance:
- Clicking ''Deny & simplify'' produces a collapsed/quiet card, not an expanded red error.
- A real Bash error still renders expanded by default.
Reference screenshot: paste-1781099488608.png (the expanded red ''Denied — this action is too complex...'' block over a follow-up retry).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 13:53:23', '2026-06-10 14:29:07', NULL, '6fad665013ede83fb4e538bb73308aa6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4QKBQY7FPCNX6N28R', 'task', '06FB0TNQM6M1VZ8Z2SZ94F0EQR', 'detect and install tmux on first launch', 'On first launch, check if tmux is on PATH. If missing, prompt the user with platform-appropriate install instructions (apt, dnf, brew) or offer to install automatically. Claude pane requires tmux per D-41; without it the primary session cannot persist.
Obsolete: superseded by D-77/D-78. The Claude pane no longer requires tmux — it is driven over the stream-json stdio control protocol and persists via --resume (transcript files), not tmux. D-77 explicitly amends D-41 (tmux-for-persistence → --resume; tmux retained only for the general terminal). The ''primary session cannot persist without tmux'' premise is gone, so a first-launch tmux detect/install gate is unwarranted. Cancelling.', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-23 20:27:28', '2026-06-10 14:41:37', NULL, '83a59c650c851a57c874e40024600520', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4QKBQY7FPCNX6N28R', 'task', '06FB0TNQM6M1VZ8Z2SZ94F0EQR', 'detect and install tmux on first launch', 'On first launch, check if tmux is on PATH. If missing, prompt the user with platform-appropriate install instructions (apt, dnf, brew) or offer to install automatically. Claude pane requires tmux per D-41; without it the primary session cannot persist.
Obsolete: superseded by D-77/D-78. The Claude pane no longer requires tmux — it is driven over the stream-json stdio control protocol and persists via --resume (transcript files), not tmux. D-77 explicitly amends D-41 (tmux-for-persistence → --resume; tmux retained only for the general terminal). The ''primary session cannot persist without tmux'' premise is gone, so a first-launch tmux detect/install gate is unwarranted. Cancelling.', 'cancelled', 'medium', NULL, NULL, NULL, '2026-04-23 20:27:28', '2026-06-10 14:41:47', NULL, 'ff8be5b975f32e6f7eb64be712a1da0d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4TQEYESK545T8F164', 'task', NULL, 'drag tab reordering in sidebar and context panel', 'Reorder tabs in the sidebar and context panel icon rails by dragging. Persist order to project settings.
Notes (2026-06-10):
1. Applies to BOTH rails — the left sidebar icon rail and the right context-bar icon rail. Reordering + persistence must work the same on each.
2. After ordering, the left-most (first) item in the rail is the one that opens by default.', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-23 20:32:06', '2026-06-10 14:45:17', NULL, '0a4deb6d577554435b76af528b170224', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM734YZ060Q63H40EYG', 'task', NULL, 'Standardize errno constants + logger in PTY/IPC/daemon', 'From the PTY/IPC error-handling audit (T-18, see docs/audits/pty-ipc-error-handling-2026-05-05.md). Two cleanup items rolled together:
**Errno constants (audit item #23):**
- Magic numbers (`4` for EINTR, `9` for EBADF, `28` for SIGWINCH, `1` for SIGHUP, `32` for EPIPE) appear inline across `lib/src/pty/session.dart` and `lib/src/pty/native_pty.dart`.
- Centralize them in `lib/src/pty/errors.dart` or a sibling `posix.dart` as named constants.
- Existing `lib/src/ipc/errno_mapping.dart` already has a `PosixErrno` class — extend it or move to a shared location both layers import from.
**Logger standardization (audit item #22, partial #26):**
- `lib/src/ipc/server.dart` uses `stderr.writeln(...)` directly; the rest of the daemon either uses no logger or a custom one.
- The Flutter-host process often consumes stderr, so log lines disappear silently.
- Pick one logger interface (kernel `log` already exists for the app side), wire `DaemonServer` and the daemon-side handlers to use it.
- Dispatch error messages should prefix with the request `cmd` so log correlation works (audit item #26).
**Out of scope for this ticket:** changes to log-LEVEL policy, log retention, log files vs stderr — pure substitution job.', 'ready', 'low', NULL, NULL, NULL, '2026-05-05 12:58:59', '2026-06-10 14:46:51', NULL, '1637ba89d9bf5d1e81a42247e6cf46bd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM734YZ060Q63H40EYG', 'task', NULL, 'Standardize errno constants + logger in PTY/IPC/daemon', 'From the PTY/IPC error-handling audit (T-18, see docs/audits/pty-ipc-error-handling-2026-05-05.md). Two cleanup items rolled together:
**Errno constants (audit item #23):**
- Magic numbers (`4` for EINTR, `9` for EBADF, `28` for SIGWINCH, `1` for SIGHUP, `32` for EPIPE) appear inline across `lib/src/pty/session.dart` and `lib/src/pty/native_pty.dart`.
- Centralize them in `lib/src/pty/errors.dart` or a sibling `posix.dart` as named constants.
- Existing `lib/src/ipc/errno_mapping.dart` already has a `PosixErrno` class — extend it or move to a shared location both layers import from.
**Logger standardization (audit item #22, partial #26):**
- `lib/src/ipc/server.dart` uses `stderr.writeln(...)` directly; the rest of the daemon either uses no logger or a custom one.
- The Flutter-host process often consumes stderr, so log lines disappear silently.
- Pick one logger interface (kernel `log` already exists for the app side), wire `DaemonServer` and the daemon-side handlers to use it.
- Dispatch error messages should prefix with the request `cmd` so log correlation works (audit item #26).
**Out of scope for this ticket:** changes to log-LEVEL policy, log retention, log files vs stderr — pure substitution job.', 'in_progress', 'low', NULL, NULL, NULL, '2026-05-05 12:58:59', '2026-06-10 14:53:03', NULL, '38f75d5cabd06dd1aa45701442eb9720', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM734YZ060Q63H40EYG', 'task', NULL, 'Standardize errno constants + logger in PTY/IPC/daemon', 'From the PTY/IPC error-handling audit (T-18, see docs/audits/pty-ipc-error-handling-2026-05-05.md). Two cleanup items rolled together:
**Errno constants (audit item #23):**
- Magic numbers (`4` for EINTR, `9` for EBADF, `28` for SIGWINCH, `1` for SIGHUP, `32` for EPIPE) appear inline across `lib/src/pty/session.dart` and `lib/src/pty/native_pty.dart`.
- Centralize them in `lib/src/pty/errors.dart` or a sibling `posix.dart` as named constants.
- Existing `lib/src/ipc/errno_mapping.dart` already has a `PosixErrno` class — extend it or move to a shared location both layers import from.
**Logger standardization (audit item #22, partial #26):**
- `lib/src/ipc/server.dart` uses `stderr.writeln(...)` directly; the rest of the daemon either uses no logger or a custom one.
- The Flutter-host process often consumes stderr, so log lines disappear silently.
- Pick one logger interface (kernel `log` already exists for the app side), wire `DaemonServer` and the daemon-side handlers to use it.
- Dispatch error messages should prefix with the request `cmd` so log correlation works (audit item #26).
**Out of scope for this ticket:** changes to log-LEVEL policy, log retention, log files vs stderr — pure substitution job.
Disposition (2026-06-10): mostly already done before pickup.
- #23 (errno constants): DONE prior. Magic numbers are centralized — errno values in lib/src/ipc/errno_mapping.dart (PosixErrno: eintr=4, ebadf=9, epipe=32, …), signals in lib/src/pty/ffi/libc.dart (sighup=1, sigwinch=28). native_pty.dart uses PosixErrno.* and libc.* throughout; no inline magic numbers remain. The ticket''s lib/src/pty/session.dart never existed at that path.
- #22 (logger): DONE prior. lib/src/ipc/server.dart imports the kernel Logger, holds a ''final Logger log'', and logs via log.error/warn/info(''ipc'', …). No stderr.writeln/print anywhere in lib/src/ipc, lib/src/pty, or lib/src/daemon. Folded in by the D-56 daemon dissolution + PTY FFI pivot.
- #26 (cmd correlation): the only live remnant — the catch-all ''dispatch threw'' log omitted the request cmd. Fixed: it now logs ''dispatch threw for "<cmd>"''. Internal logging only; no changelog.', 'in_progress', 'low', NULL, NULL, NULL, '2026-05-05 12:58:59', '2026-06-10 14:58:32', NULL, '97a7a5a0bb8841f3a586c8b559a42819', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM734YZ060Q63H40EYG', 'task', NULL, 'Standardize errno constants + logger in PTY/IPC/daemon', 'From the PTY/IPC error-handling audit (T-18, see docs/audits/pty-ipc-error-handling-2026-05-05.md). Two cleanup items rolled together:
**Errno constants (audit item #23):**
- Magic numbers (`4` for EINTR, `9` for EBADF, `28` for SIGWINCH, `1` for SIGHUP, `32` for EPIPE) appear inline across `lib/src/pty/session.dart` and `lib/src/pty/native_pty.dart`.
- Centralize them in `lib/src/pty/errors.dart` or a sibling `posix.dart` as named constants.
- Existing `lib/src/ipc/errno_mapping.dart` already has a `PosixErrno` class — extend it or move to a shared location both layers import from.
**Logger standardization (audit item #22, partial #26):**
- `lib/src/ipc/server.dart` uses `stderr.writeln(...)` directly; the rest of the daemon either uses no logger or a custom one.
- The Flutter-host process often consumes stderr, so log lines disappear silently.
- Pick one logger interface (kernel `log` already exists for the app side), wire `DaemonServer` and the daemon-side handlers to use it.
- Dispatch error messages should prefix with the request `cmd` so log correlation works (audit item #26).
**Out of scope for this ticket:** changes to log-LEVEL policy, log retention, log files vs stderr — pure substitution job.
Disposition (2026-06-10): mostly already done before pickup.
- #23 (errno constants): DONE prior. Magic numbers are centralized — errno values in lib/src/ipc/errno_mapping.dart (PosixErrno: eintr=4, ebadf=9, epipe=32, …), signals in lib/src/pty/ffi/libc.dart (sighup=1, sigwinch=28). native_pty.dart uses PosixErrno.* and libc.* throughout; no inline magic numbers remain. The ticket''s lib/src/pty/session.dart never existed at that path.
- #22 (logger): DONE prior. lib/src/ipc/server.dart imports the kernel Logger, holds a ''final Logger log'', and logs via log.error/warn/info(''ipc'', …). No stderr.writeln/print anywhere in lib/src/ipc, lib/src/pty, or lib/src/daemon. Folded in by the D-56 daemon dissolution + PTY FFI pivot.
- #26 (cmd correlation): the only live remnant — the catch-all ''dispatch threw'' log omitted the request cmd. Fixed: it now logs ''dispatch threw for "<cmd>"''. Internal logging only; no changelog.', 'done', 'low', NULL, NULL, NULL, '2026-05-05 12:58:59', '2026-06-10 14:58:43', NULL, 'd7ba06c0e311a1945f8ccead0b132570', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM42M9RK4399B4F4WSG', 'story', NULL, 'VS Code keybinding preset', 'Ship a VS Code-compatible keybinding preset that maps standard VS Code shortcuts to clide commands. Users select it in settings. Covers file navigation, editor actions, panel toggles, search, and terminal.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is now in place. Implementation is now just authoring `assets/keymaps/vscode.yaml` against the typed Intents in `lib/kernel/src/keymap/intents.dart` (plus `command:<id>` bindings for VS-Code-specific commands the preset wants to bind to clide commands). Users will switch presets via `app.keymap.preset = vscode` once a settings UI exists, or directly via the setting today.
**Acceptance:**
1. `assets/keymaps/vscode.yaml` ships covering the documented VS Code default keybindings.
2. `KeymapService.setPreset("vscode")` activates the preset and all asserted bindings resolve as expected.
3. The preset uses when-clauses where VS Code does (`editor.focused`, `inputFocused`, `palette.open`, …).
4. A regression test loads the preset and asserts a representative subset (e.g. ctrl+p → quick-open command, ctrl+shift+p → palette).
**Out of scope:** clide commands that have no VS Code analogue (those keep their default-preset bindings).', 'ready', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-10 15:01:43', NULL, 'b853e77093d133d712104ef3a900762a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM60QRRNEEWG84VWKXC', 'story', NULL, 'JetBrains keybinding preset', 'Ship a JetBrains/IntelliJ-compatible keybinding preset mapping standard JetBrains shortcuts to clide commands. Covers navigation, refactoring, search, run/debug, and tool windows.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place. Implementation is authoring `assets/keymaps/jetbrains.yaml` against the typed Intents + `command:<id>` bindings, plus when-clauses for the contexts JetBrains presets typically scope to (`editor.focused`, `inputFocused`, etc.).
**Acceptance:**
1. `assets/keymaps/jetbrains.yaml` ships covering the documented IntelliJ default keybindings.
2. `KeymapService.setPreset("jetbrains")` activates the preset and all asserted bindings resolve.
3. A regression test exercises a representative subset (e.g. shift+shift → quick-open command — see Q-9 if the search-everywhere overlay needs its own intent).', 'ready', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-10 15:01:52', NULL, '21b160e1724fea9e39aec2884e50904b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM42M9RK4399B4F4WSG', 'story', NULL, 'VS Code keybinding preset', 'Ship a VS Code-compatible keybinding preset that maps standard VS Code shortcuts to clide commands. Users select it in settings. Covers file navigation, editor actions, panel toggles, search, and terminal.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is now in place. Implementation is now just authoring `assets/keymaps/vscode.yaml` against the typed Intents in `lib/kernel/src/keymap/intents.dart` (plus `command:<id>` bindings for VS-Code-specific commands the preset wants to bind to clide commands). Users will switch presets via `app.keymap.preset = vscode` once a settings UI exists, or directly via the setting today.
**Acceptance:**
1. `assets/keymaps/vscode.yaml` ships covering the documented VS Code default keybindings.
2. `KeymapService.setPreset("vscode")` activates the preset and all asserted bindings resolve as expected.
3. The preset uses when-clauses where VS Code does (`editor.focused`, `inputFocused`, `palette.open`, …).
4. A regression test loads the preset and asserts a representative subset (e.g. ctrl+p → quick-open command, ctrl+shift+p → palette).
**Out of scope:** clide commands that have no VS Code analogue (those keep their default-preset bindings).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-10 15:01:54', NULL, 'e714ae4f4d0fc2f6a4f35e1c84b53ee9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM60QRRNEEWG84VWKXC', 'story', NULL, 'JetBrains keybinding preset', 'Ship a JetBrains/IntelliJ-compatible keybinding preset mapping standard JetBrains shortcuts to clide commands. Covers navigation, refactoring, search, run/debug, and tool windows.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place. Implementation is authoring `assets/keymaps/jetbrains.yaml` against the typed Intents + `command:<id>` bindings, plus when-clauses for the contexts JetBrains presets typically scope to (`editor.focused`, `inputFocused`, etc.).
**Acceptance:**
1. `assets/keymaps/jetbrains.yaml` ships covering the documented IntelliJ default keybindings.
2. `KeymapService.setPreset("jetbrains")` activates the preset and all asserted bindings resolve.
3. A regression test exercises a representative subset (e.g. shift+shift → quick-open command — see Q-9 if the search-everywhere overlay needs its own intent).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-10 15:02:14', NULL, 'a08e16020c92d6e38b94158c42b0e877', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB44SKPKTHFMV6WD28GZYPXM', 'task', NULL, 'Support double-tap / bare-modifier chords (JetBrains Search Everywhere)', 'The KeyChord matcher can''t represent a bare or double-tapped modifier: KeyChord.fromKeyEvent returns null for bare modifier presses (key_chord.dart _isBareModifier), and KeyChord.parse requires a base key after the last ''+'', so ''shift+shift'' / ''shift shift'' both fail. Consequence: JetBrains ''Search Everywhere'' (double-Shift) and any double-tap-modifier shortcut can''t be bound.
Decide (design): is search-everywhere its own intent + overlay, or an alias of quick-open? Then extend the chord/sequence matcher to represent a bare-modifier ''key'' (and a double-tap as a sequence) so a preset can bind it.
Surfaced by T-64/T-66 (VS Code / JetBrains keymap presets). Those tickets'' ''see Q-9'' reference is STALE — Q-9 is ''Lua runtime vendoring'', unrelated. The JetBrains preset currently maps quick-open to Ctrl+Shift+N (Go to File) and the palette to Ctrl+Shift+A (Find Action) as the expressible IntelliJ equivalents.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 15:07:43', '2026-06-10 15:07:43', NULL, 'e4a9db7e1d1759ebd6a8ad97ab4e9aca', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM42M9RK4399B4F4WSG', 'story', NULL, 'VS Code keybinding preset', 'Ship a VS Code-compatible keybinding preset that maps standard VS Code shortcuts to clide commands. Users select it in settings. Covers file navigation, editor actions, panel toggles, search, and terminal.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is now in place. Implementation is now just authoring `assets/keymaps/vscode.yaml` against the typed Intents in `lib/kernel/src/keymap/intents.dart` (plus `command:<id>` bindings for VS-Code-specific commands the preset wants to bind to clide commands). Users will switch presets via `app.keymap.preset = vscode` once a settings UI exists, or directly via the setting today.
**Acceptance:**
1. `assets/keymaps/vscode.yaml` ships covering the documented VS Code default keybindings.
2. `KeymapService.setPreset("vscode")` activates the preset and all asserted bindings resolve as expected.
3. The preset uses when-clauses where VS Code does (`editor.focused`, `inputFocused`, `palette.open`, …).
4. A regression test loads the preset and asserts a representative subset (e.g. ctrl+p → quick-open command, ctrl+shift+p → palette).
**Out of scope:** clide commands that have no VS Code analogue (those keep their default-preset bindings).
Correction (2026-06-10): this ticket''s ''see Q-9'' reference is stale — Q-9 is ''Lua runtime vendoring'', unrelated. The search-everywhere / double-tap-modifier gap is now tracked by T-341.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-10 15:07:52', NULL, '4a85b3c69805401cba51e521e9ba7d5d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM60QRRNEEWG84VWKXC', 'story', NULL, 'JetBrains keybinding preset', 'Ship a JetBrains/IntelliJ-compatible keybinding preset mapping standard JetBrains shortcuts to clide commands. Covers navigation, refactoring, search, run/debug, and tool windows.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place. Implementation is authoring `assets/keymaps/jetbrains.yaml` against the typed Intents + `command:<id>` bindings, plus when-clauses for the contexts JetBrains presets typically scope to (`editor.focused`, `inputFocused`, etc.).
**Acceptance:**
1. `assets/keymaps/jetbrains.yaml` ships covering the documented IntelliJ default keybindings.
2. `KeymapService.setPreset("jetbrains")` activates the preset and all asserted bindings resolve.
3. A regression test exercises a representative subset (e.g. shift+shift → quick-open command — see Q-9 if the search-everywhere overlay needs its own intent).
Correction (2026-06-10): ''see Q-9'' is stale (Q-9 is Lua runtime vendoring). The double-Shift ''Search Everywhere'' chord is NOT expressible by the current matcher (bare/double modifiers unsupported) — tracked in T-341. This preset maps quick-open to Ctrl+Shift+N and the palette to Ctrl+Shift+A as the expressible IntelliJ equivalents.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-10 15:07:52', NULL, '067b925b777ecce5c0c25239193989f6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5YG22EV7BFTX5RTPR', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'web-tree-sitter via JS interop for browser builds', 'Conditional import behind TreeSitterService: native impl uses dart:ffi to libtree-sitter.so, web impl uses dart:js_interop to web-tree-sitter (official emscripten build from tree-sitter org). Same grammar .wasm files on both platforms. Vendor web-tree-sitter .wasm + JS glue as Flutter web assets, pinned version, added to licenses.yaml.
Cancelled 2026-06-10 (backlog relevance sweep): contradicts the desktop-first guardrail (CLAUDE.md) - web is an explicit non-goal / happy-accident only. TreeSitterService is FFI-only and there is no shipped web product, so a web-tree-sitter dual-path is not wanted. Reopen only if web ever becomes a real target.', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-22 20:14:33', '2026-06-10 15:13:50', NULL, 'dd1bb8fd534cfa37647528f4ed68da52', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM50X0DX8XTVZEA5GN8', 'story', NULL, 'workspace trust prompt', 'Prompt before running extensions or loading project settings in untrusted repositories. Trust decision persisted per repo path. Untrusted mode disables third-party extensions and restricts IPC commands.
Cancelled 2026-06-10 (relevance sweep): premature. Third-party (Lua) extension loading is not shipped - ExtensionScanner.discover is test-only and the Lua runtime is a Tier-6 skeleton. Nothing to trust-gate yet; revisit at Tier 6 when external extension loading lands (the trust surface will likely be Lua sandboxing per D-19, not a per-repo prompt).', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-23 20:32:06', '2026-06-10 15:13:52', NULL, '0edf2435dec17eca4a3fd8a605cc2afe', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6RDM2EKZX132GPV7M', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'Add PRs tab to sidebar', 'Spec lists PRs as a left-panel section (icon rail position 5). No extension exists yet.
Cancelled 2026-06-10 (relevance sweep): spec''d in D-47 but unscoped, no extension exists, and the data path (git host API vs local metadata) is undecided. Closing to clear the backlog; file a fresh scoped story if a PRs surface is wanted.', 'backlog', 'low', NULL, NULL, 'D-47', '2026-04-22 21:03:40', '2026-06-10 15:13:52', NULL, '8fa9643461ef70f261fc895639d15811', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4W194B2421P2SF83R', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'CI reproducible build for libtree-sitter.so', 'BUILD.md at app/native/linux-x64/ has TODO checklist: build from pinned source SHA in CI, record SHA-256, cross-compile for macOS (aarch64, x86_64) and Windows (x86_64). Currently built on contributor machine.
Path fix (2026-06-10 sweep): ticket says app/native/linux-x64/ - the app/ prefix is stale (D-56 dissolved the two-package layout). Correct path is native/linux-x64/BUILD.md. Work remains valid: native/linux-x64/libtree-sitter.so is committed but there is still no CI build/cross-compile job.', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-22 20:14:24', '2026-06-10 15:13:54', NULL, 'c52de8ef9eb80fae7cda60ffb23f8117', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM48MDE8ZZ82VWNY994', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'migrate shipped extensions from builtin/ to extensions/ per D-46', 'D-46 defines the boundary: content extensions (editor, claude, claude-control, markdown, diff, git-ui, pql, canvas, graph, decisions, tickets, todos, problems) move from app/lib/builtin/ to app/lib/extensions/. Incremental — one at a time, each behind a working build. Extension contract must support bundled Dart extension as a first-class category.
Path fix (2026-06-10 sweep): app/lib/builtin/ -> lib/builtin/ (app/ prefix stale per D-56). D-46 still confirmed/active. lib/extensions/ does not exist yet and the shipped extensions (editor, claude, markdown, diff, git-ui, pql, canvas, graph, decisions, tickets, todos, problems) are still under lib/builtin/. Migration unstarted, still valid.', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-22 20:14:45', '2026-06-10 15:13:56', NULL, '8b66e304cc2695c4850917dad1e53de1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6T6580D8ABDVTMNZW', 'epic', NULL, 'Tier 6 — extension API, settings, theming, distributable builds', 'Tier 6 of the build plan: the things that make clide a real product instead of a working prototype.
**Extension API (third-party Lua):**
- The Lua runtime supporter tool (D-19) lands as a peer of pql/ptyc.
- Manifest schema, capability gating, sandboxed FS/IPC access.
- Same TabContribution / CommandContribution / etc. surface as built-in Dart extensions (D-15).
- Marketplace / distribution story is OUT OF SCOPE for Tier 6 — local-install only.
**Settings UI (`builtin.settings-ui`):**
- Schema-driven settings panel reading from the kernel SettingsStore.
- Render strategy: form fields keyed off the schema each subsystem registers.
- Edits write back to `.clide/settings.yaml`.
**Theming UI (`builtin.theme-picker` extends):**
- Live preview of the four bundled themes (D-44).
- Custom theme: import YAML, validate against schema, register at runtime.
- Per-component override surface (long horizon).
**Distributable builds:**
- AppImage / Flatpak for Linux, .dmg for macOS — see T-46.
- Self-update mechanism — see T-47.
- License manifest auto-regen as part of the release build.
Big epic — children land incrementally. Most concrete child tickets already exist; this is the umbrella.
Status note (2026-06-10 sweep): mixed completion. theme-picker is substantially implemented; settings-ui is a stub; the Lua runtime is skeleton-only (lib/lua/); distributable builds (T-46/T-47) remain deferred Tier-6 work. Epic stays open as the umbrella.', 'backlog', 'medium', NULL, NULL, 'D-15', '2026-04-22 11:45:41', '2026-06-10 15:13:57', NULL, 'bcccbd9609f6aba3fcfd3117f61af377', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM66FTCTWHH9AQTNFKR', 'story', NULL, 'system tray and dock persistence', 'Minimize to system tray on Linux (AppIndicator) or Dock on macOS. Reopening from tray restores the window without cold boot. tmux sessions stay alive in background regardless.
Scope split (2026-06-10 sweep): the session-persistence half is effectively done - tmux keeps Claude/terminal sessions alive across restart (D-41). The OS-tray/AppIndicator + dock half is a stub only (lib/kernel/src/tray.dart - TrayRegistry has no platform-channel wiring) and is Tier-6+. Remaining work = the tray integration.', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-23 20:32:06', '2026-06-10 15:13:59', NULL, 'ab5f60da82d9e23bf6104099b2fa9af0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM67JSC5RKS6M9182KG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Add images tab to context panel', 'Spec lists images as a right-panel section. No extension exists yet.
Relevance note (2026-06-10 sweep): likely superseded. The image card + full-screen lightbox shipped (T-249/T-252) and the canvas epic (T-317, D-91) folds image display into the unified drawing-card renderer rather than a separate context-panel tab. Confirm whether a distinct images rail section is still wanted; otherwise close in favor of the canvas path.', 'backlog', 'low', NULL, NULL, 'D-47', '2026-04-22 21:03:40', '2026-06-10 15:14:01', NULL, 'f81afd3f2563491ccd7325ced11e5e25', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7P6Q0DG4RG4CBHFJG', 'story', NULL, 'review Semantics widget coverage and UI testability', 'Audit all interactive widgets for Semantics coverage (labels, roles, states). Verify flutter test can locate and interact with every panel, button, and input via find.bySemanticsLabel. Run the existing a11y test suite and document gaps. Target: every user-facing action is testable without widget keys.
Reframe (2026-06-10 sweep): the original ''audit Semantics coverage'' framing is stale - test/a11y/ (semantic_coverage, contrast, keyboard_traversal, i18n) is now a mature per-PR gate per D-20. Re-scope to forward work: ratchet the semantic-coverage floor and deepen per-extension Semantics assertions, rather than a one-time review.', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-10 15:14:02', NULL, '6d8c5bcd384a18ffa8f34f642c4f680e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM79CNBXJ2S3CFQR7VM', 'task', NULL, 'Misc PTY/IPC/git polish (audit medium-priority items)', 'Catch-all for the medium-priority items from the PTY/IPC error-handling audit (T-18, see docs/audits/pty-ipc-error-handling-2026-05-05.md) that didn`t earn dedicated tickets:
- **#17** — `files.read` `readAsStringSync` is unguarded; UTF-8 errors / permissions / mid-read deletion become 500-style dispatch errors. Wrap in try/catch and emit a clean `IpcResponse.err`.
- **#19** — `PtySession.close` swallows the 500ms timeout silently (`onTimeout: () {}`). Log when the timeout fires so we know SIGKILL was needed.
- **#20** — Reader isolate treats every negative `read()` return that isn`t EINTR as EOF. Distinguish EBADF/EIO (real EOF) from transient EAGAIN (recoverable) and log the latter.
- **#21** — `scm_rights.dart` reads cmsg-data fd without verifying `dataOffset + 4 <= msgControllen`. Bounds check before deref so a malformed peer can`t feed garbage as an fd.
- **#25** — `_gitError` in `lib/src/daemon/git_commands.dart` always reports `tool_error`; push rejections / merge conflicts should map to `IpcExitCode.conflict` when stderr matches known patterns.
- **#27** — `pane.spawn` returns `ok` even when `registry.write(id, bytes)` returned `n == -1`. Distinguish the failure.
- **#28** — `IpcResponse.fromJson` throws `TypeError` on a malformed peer response missing `error`. Graceful degrade.
- **#29** — PATH resolution in `native_pty.dart` uses the first existing match without `X_OK` check; non-executable files shadow valid binaries further along PATH.
Land each as a small focused commit; ticket closes when all items above are merged.
Item status (2026-06-10 sweep): from the T-18 audit, #16 (git error kinds) landed via T-79 and #22 (logging) via T-80. #21 (scm_rights.dart bounds check) is OBSOLETE - fd-passing/recvmsg was removed, the file no longer exists; drop it. Spot-checked still-open: #17 files.read unguarded readAsStringSync (files_commands.dart), #28 IpcResponse.fromJson TypeError (envelope.dart), #29 PATH X_OK check (native_pty.dart). ~7 items remain.', 'backlog', 'low', NULL, NULL, NULL, '2026-05-05 12:58:59', '2026-06-10 15:14:04', NULL, 'd98147f71017f0d6b3b8e0688c2760c9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5YG22EV7BFTX5RTPR', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'web-tree-sitter via JS interop for browser builds', 'Conditional import behind TreeSitterService: native impl uses dart:ffi to libtree-sitter.so, web impl uses dart:js_interop to web-tree-sitter (official emscripten build from tree-sitter org). Same grammar .wasm files on both platforms. Vendor web-tree-sitter .wasm + JS glue as Flutter web assets, pinned version, added to licenses.yaml.
Cancelled 2026-06-10 (backlog relevance sweep): contradicts the desktop-first guardrail (CLAUDE.md) - web is an explicit non-goal / happy-accident only. TreeSitterService is FFI-only and there is no shipped web product, so a web-tree-sitter dual-path is not wanted. Reopen only if web ever becomes a real target.', 'cancelled', 'medium', NULL, NULL, NULL, '2026-04-22 20:14:33', '2026-06-10 15:14:17', NULL, '366c0ac563b3f001d6e783dda9cc60f2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM50X0DX8XTVZEA5GN8', 'story', NULL, 'workspace trust prompt', 'Prompt before running extensions or loading project settings in untrusted repositories. Trust decision persisted per repo path. Untrusted mode disables third-party extensions and restricts IPC commands.
Cancelled 2026-06-10 (relevance sweep): premature. Third-party (Lua) extension loading is not shipped - ExtensionScanner.discover is test-only and the Lua runtime is a Tier-6 skeleton. Nothing to trust-gate yet; revisit at Tier 6 when external extension loading lands (the trust surface will likely be Lua sandboxing per D-19, not a per-repo prompt).', 'cancelled', 'medium', NULL, NULL, NULL, '2026-04-23 20:32:06', '2026-06-10 15:14:17', NULL, '34d12878273efad542a07a4e96eef3ac', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6RDM2EKZX132GPV7M', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'Add PRs tab to sidebar', 'Spec lists PRs as a left-panel section (icon rail position 5). No extension exists yet.
Cancelled 2026-06-10 (relevance sweep): spec''d in D-47 but unscoped, no extension exists, and the data path (git host API vs local metadata) is undecided. Closing to clear the backlog; file a fresh scoped story if a PRs surface is wanted.', 'cancelled', 'low', NULL, NULL, 'D-47', '2026-04-22 21:03:40', '2026-06-10 15:14:17', NULL, '2d35befb6eebdc0a5778c26887622e85', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM42M9RK4399B4F4WSG', 'story', NULL, 'VS Code keybinding preset', 'Ship a VS Code-compatible keybinding preset that maps standard VS Code shortcuts to clide commands. Users select it in settings. Covers file navigation, editor actions, panel toggles, search, and terminal.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is now in place. Implementation is now just authoring `assets/keymaps/vscode.yaml` against the typed Intents in `lib/kernel/src/keymap/intents.dart` (plus `command:<id>` bindings for VS-Code-specific commands the preset wants to bind to clide commands). Users will switch presets via `app.keymap.preset = vscode` once a settings UI exists, or directly via the setting today.
**Acceptance:**
1. `assets/keymaps/vscode.yaml` ships covering the documented VS Code default keybindings.
2. `KeymapService.setPreset("vscode")` activates the preset and all asserted bindings resolve as expected.
3. The preset uses when-clauses where VS Code does (`editor.focused`, `inputFocused`, `palette.open`, …).
4. A regression test loads the preset and asserts a representative subset (e.g. ctrl+p → quick-open command, ctrl+shift+p → palette).
**Out of scope:** clide commands that have no VS Code analogue (those keep their default-preset bindings).
Correction (2026-06-10): this ticket''s ''see Q-9'' reference is stale — Q-9 is ''Lua runtime vendoring'', unrelated. The search-everywhere / double-tap-modifier gap is now tracked by T-341.', 'done', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-10 15:17:59', NULL, 'f4c92b7fb7bbf4e1a2afb9b02f223822', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM60QRRNEEWG84VWKXC', 'story', NULL, 'JetBrains keybinding preset', 'Ship a JetBrains/IntelliJ-compatible keybinding preset mapping standard JetBrains shortcuts to clide commands. Covers navigation, refactoring, search, run/debug, and tool windows.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place. Implementation is authoring `assets/keymaps/jetbrains.yaml` against the typed Intents + `command:<id>` bindings, plus when-clauses for the contexts JetBrains presets typically scope to (`editor.focused`, `inputFocused`, etc.).
**Acceptance:**
1. `assets/keymaps/jetbrains.yaml` ships covering the documented IntelliJ default keybindings.
2. `KeymapService.setPreset("jetbrains")` activates the preset and all asserted bindings resolve.
3. A regression test exercises a representative subset (e.g. shift+shift → quick-open command — see Q-9 if the search-everywhere overlay needs its own intent).
Correction (2026-06-10): ''see Q-9'' is stale (Q-9 is Lua runtime vendoring). The double-Shift ''Search Everywhere'' chord is NOT expressible by the current matcher (bare/double modifiers unsupported) — tracked in T-341. This preset maps quick-open to Ctrl+Shift+N and the palette to Ctrl+Shift+A as the expressible IntelliJ equivalents.', 'done', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-10 15:17:59', NULL, '58c6bc49c1ec261dfffc3d5d60fc44a5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB493JEW32CH0H3771TNHF7G', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Give each spawned subagent its own collapsing activity card (don''t merge Task spawns)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 15:26:33', '2026-06-10 15:26:33', NULL, '49ed852675b4f44fcef84d079146f207', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB493JEW32CH0H3771TNHF7G', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Give each spawned subagent its own collapsing activity card (don''t merge Task spawns)', 'Filed 2026-06-10 from a user request: when the agent fans out multiple subagents (Task/Agent tool), each spawned subagent should get its OWN collapsing activity card. The space is worth it — a fan-out of N agents should read as N cards, not one lumped card.
CURRENT BEHAVIOUR (confirmed): multiple Task/Agent spawns are MERGED into a single shared ''Activity / N steps'' cluster. groupConversation() in lib/builtin/claude/src/activity_cluster.dart:120-147 walks items and coalesces every consecutive _isFoldable item into one FoldedCluster. _isFoldable (:149-172, AssistantToolUse case at :161-164) only distinguishes diff tools (Edit/Write/MultiEdit/NotebookEdit/Update -> stay first-class) from everything else (Task/Agent/Bash/Read/... -> all foldable). The Task tool is treated identically to a Bash/Read call; there is NO subagent-aware grouping key (not toolUseId, not parent_tool_use_id). So 4 spawned agents render as one ''Activity 4 steps'' card.
What recent work already does (do NOT redo): T-263 folds the subagent PROMPT into the Agent card; T-264 nests the subagent''s RUN items under the parent Agent card (the ''agent run'' collapser); T-338 routes sidechain items to their parent via parent_tool_use_id. All of that is about what shows INSIDE one agent''s card. This ticket is the complement: stop merging DISTINCT agent spawns into a shared cluster.
SCOPE / DESIGN:
- An AssistantToolUse where _isAgentTool(name) (Task/Agent) should break the current Activity cluster and render as its own first-class collapsing card (its own ClideCollapserCard with the prompt + nested ''agent run'' from T-263/T-264), rather than folding into the generic Activity cluster with sibling tool calls.
- Decide grouping precisely in groupConversation/_isFoldable: an Agent tool-use is a cluster boundary (like a sticky item) OR emits its own single-item card. Adjacent non-agent foldables (Bash/Read/Grep) keep clustering into the normal Activity card as today.
- Label each subagent card by its task/description (the Agent call''s label) so parallel fan-outs are distinguishable, not ''Activity N steps''.
- Keep collapsed-by-default behaviour and the FoldLevel semantics; this changes the grouping boundary, not the fold mechanics.
Refs: lib/builtin/claude/src/activity_cluster.dart (groupConversation, _isFoldable, isDiffTool), lib/builtin/claude/src/conversation_view.dart (_ActivityCard at ~833, _toolUseCollapser ~615-657, _isAgentTool ~378). Related: T-230 (clustering), T-263, T-264, T-338.
Tests: a groupConversation case asserting that two consecutive Agent tool-uses yield two separate cards (not one FoldedCluster), while two consecutive Bash calls still yield one Activity cluster.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 15:26:33', '2026-06-10 15:27:01', NULL, 'd82afb6688c82c068d6020a279652736', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB493JEW32CH0H3771TNHF7G', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Give each spawned subagent its own collapsing activity card (don''t merge Task spawns)', 'Filed 2026-06-10 from a user request: when the agent fans out multiple subagents (Task/Agent tool), each spawned subagent should get its OWN collapsing activity card. The space is worth it — a fan-out of N agents should read as N cards, not one lumped card.
CURRENT BEHAVIOUR (confirmed): multiple Task/Agent spawns are MERGED into a single shared ''Activity / N steps'' cluster. groupConversation() in lib/builtin/claude/src/activity_cluster.dart:120-147 walks items and coalesces every consecutive _isFoldable item into one FoldedCluster. _isFoldable (:149-172, AssistantToolUse case at :161-164) only distinguishes diff tools (Edit/Write/MultiEdit/NotebookEdit/Update -> stay first-class) from everything else (Task/Agent/Bash/Read/... -> all foldable). The Task tool is treated identically to a Bash/Read call; there is NO subagent-aware grouping key (not toolUseId, not parent_tool_use_id). So 4 spawned agents render as one ''Activity 4 steps'' card.
What recent work already does (do NOT redo): T-263 folds the subagent PROMPT into the Agent card; T-264 nests the subagent''s RUN items under the parent Agent card (the ''agent run'' collapser); T-338 routes sidechain items to their parent via parent_tool_use_id. All of that is about what shows INSIDE one agent''s card. This ticket is the complement: stop merging DISTINCT agent spawns into a shared cluster.
SCOPE / DESIGN:
- An AssistantToolUse where _isAgentTool(name) (Task/Agent) should break the current Activity cluster and render as its own first-class collapsing card (its own ClideCollapserCard with the prompt + nested ''agent run'' from T-263/T-264), rather than folding into the generic Activity cluster with sibling tool calls.
- Decide grouping precisely in groupConversation/_isFoldable: an Agent tool-use is a cluster boundary (like a sticky item) OR emits its own single-item card. Adjacent non-agent foldables (Bash/Read/Grep) keep clustering into the normal Activity card as today.
- Label each subagent card by its task/description (the Agent call''s label) so parallel fan-outs are distinguishable, not ''Activity N steps''.
- Keep collapsed-by-default behaviour and the FoldLevel semantics; this changes the grouping boundary, not the fold mechanics.
Refs: lib/builtin/claude/src/activity_cluster.dart (groupConversation, _isFoldable, isDiffTool), lib/builtin/claude/src/conversation_view.dart (_ActivityCard at ~833, _toolUseCollapser ~615-657, _isAgentTool ~378). Related: T-230 (clustering), T-263, T-264, T-338.
Tests: a groupConversation case asserting that two consecutive Agent tool-uses yield two separate cards (not one FoldedCluster), while two consecutive Bash calls still yield one Activity cluster.
SCOPE CLARIFICATION (2026-06-10, from user):
1. RIGHT card, not just A card. Each per-agent card must pull in ALL of that agent''s nested run — the folded prompt (T-263) AND every nested response: prose, thinking, sidechain tool cards, and results (T-264) — attributed to the CORRECT agent even under a parallel fan-out where multiple agents'' sidechain items interleave in the stream. Reuse the existing _sidechainFold machinery (conversation_view.dart:188-256): runByToolUseId / promptsByToolUseId are already keyed by the owning Agent''s toolUseId, and resolveOwner''s direct route (conversation_view.dart:210-217) uses parent_tool_use_id (T-338) which disambiguates concurrent agents correctly. THE HAZARD: resolveOwner falls back to ''nearest'' = lastAgent (the most-recently-emitted Agent in stream order; conversation_view.dart:228, applied at :242). In a parallel fan-out any item that lacks parent_tool_use_id and a rooted parentUuid chain would mis-route to whichever agent was emitted last — landing in the WRONG card. Harden this: for the multi-agent case, drop or guard the nearest-lastAgent fallback so an unattributable item is rendered inline/orphaned (resolveOwner already returns null -> handled at :243) rather than mis-filed into a sibling agent''s card.
2. PRESERVE the existing grouping. This ticket only adds an Agent-spawn cluster boundary; it must NOT regress the rest:
- Non-agent foldables (Bash/Read/Grep/LS/etc.) keep coalescing into the generic ''Activity / N steps'' cluster exactly as today (activity_cluster.dart groupConversation/_isFoldable).
- The intra-agent folding stays: prompt-into-call (T-263), run-nested-under-card (T-264), sidechain routing by parent_tool_use_id (T-338). Reuse them; do not rebuild.
- Net behaviour: a fan-out of N agents -> N distinct collapsed cards, each containing its own complete run; surrounding non-agent tool calls still group into their normal Activity card.
Test additions: (a) two concurrent agents whose sidechain items interleave -> each agent''s run items land under its own card, none cross-attributed; (b) an unattributable sidechain item (no parent_tool_use_id, broken chain) is NOT swept into the nearest agent''s card; (c) regression: consecutive Bash/Read calls still form one Activity cluster.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 15:26:33', '2026-06-10 15:28:52', NULL, '05bc2daa013de8cf163cdd54c6014f07', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4FDREHRYRR7B9ER72KCQKC', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ticket panel: per-type filter chips (Bug / Ticket / Epic / Initiative)', 'Add a row of type-filter toggle chips at the top of the tickets panel, directly below the "Filter tickets…" box (no section header — the chips read on their own). One chip per ticket type the user thinks in: Bug, Ticket, Epic, Initiative. All four ON by default.
## Behaviour
- Single-click a chip → toggle that type in/out of the list.
- Double-click a chip → isolate (solo) that type: turns it ON and all others OFF. Double-click the same chip again → restore all to ON. This is the chart-legend solo pattern (Plotly/Tableau/Grafana) — learnable and fully reversible.
- Last-off resets to all-on: disabling the final remaining type snaps all chips back ON. An empty type filter means "no filter", so the list is never mysteriously blank.
- Tooltip per chip: "Click to toggle · double-click to isolate".
## Type mapping (pql → chip)
pql ticket types are initiative, epic, story, task, bug (see lib/builtin/tickets/src/ticket_colors.dart). The four chips map as:
- Bug → bug
- Ticket → story + task (leaf work items)
- Epic → epic
- Initiative → initiative
Each chip carries its type-colored dot + border using TicketTypeColors (bug #E87D7D, story/task green/grey, epic #78A0F8, initiative #C792EA).
## Filtering
- Type filter is ANDed with the existing text filter in _TicketsViewState (lib/builtin/tickets/src/tickets_view.dart): a ticket shows only if its type is enabled AND it matches the text filter.
- When the type filter hides all items in a status section, that section collapses out (same as text-filter behaviour today).
## Implementation notes
- Active-chip visual: filled tint + type-colored border (active) vs muted/no border (inactive) — reuse the _Toggle pattern from lib/builtin/search/src/search_panel_view.dart and ClideTappable.
- Persist nothing across sessions for v1 (always all-on on load); revisit if requested.
- a11y: Semantics(button, toggled) per chip, mirroring the search-panel toggle.
## Wireframe
docs/design/wireframes/tickets/ticket-type-filters.json (+ .png export)', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 15:54:09', '2026-06-10 15:54:09', NULL, '3e7c03bbb536a4c2d45f770abfee8b13', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4FDREHRYRR7B9ER72KCQKC', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ticket panel: per-type filter chips (Bug / Ticket / Epic / Initiative)', 'Add a row of type-filter toggle chips at the top of the tickets panel, directly below the "Filter tickets…" box (no section header — the chips read on their own). One chip per ticket type the user thinks in: Bug, Ticket, Epic, Initiative. All four ON by default.
## Behaviour
- Single-click a chip → toggle that type in/out of the list.
- Double-click a chip → isolate (solo) that type: turns it ON and all others OFF. Double-click the same chip again → restore all to ON. This is the chart-legend solo pattern (Plotly/Tableau/Grafana) — learnable and fully reversible.
- Last-off resets to all-on: disabling the final remaining type snaps all chips back ON. An empty type filter means "no filter", so the list is never mysteriously blank.
- Tooltip per chip: "Click to toggle · double-click to isolate".
## Type mapping (pql → chip)
pql ticket types are initiative, epic, story, task, bug (see lib/builtin/tickets/src/ticket_colors.dart). The four chips map as:
- Bug → bug
- Ticket → story + task (leaf work items)
- Epic → epic
- Initiative → initiative
Each chip carries its type-colored dot + border using TicketTypeColors (bug #E87D7D, story/task green/grey, epic #78A0F8, initiative #C792EA).
## Filtering
- Type filter is ANDed with the existing text filter in _TicketsViewState (lib/builtin/tickets/src/tickets_view.dart): a ticket shows only if its type is enabled AND it matches the text filter.
- When the type filter hides all items in a status section, that section collapses out (same as text-filter behaviour today).
## Implementation notes
- Active-chip visual: filled tint + type-colored border (active) vs muted/no border (inactive) — reuse the _Toggle pattern from lib/builtin/search/src/search_panel_view.dart and ClideTappable.
- Persist nothing across sessions for v1 (always all-on on load); revisit if requested.
- a11y: Semantics(button, toggled) per chip, mirroring the search-panel toggle.
## Wireframe
docs/design/wireframes/tickets/ticket-type-filters.json (+ .png export)', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 15:54:09', '2026-06-10 15:54:23', NULL, 'f5614424c00196aabab868db75483bf2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4FDREHRYRR7B9ER72KCQKC', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ticket panel: per-type filter chips (Bug / Ticket / Epic / Initiative)', 'Add a row of type-filter toggle chips at the top of the tickets panel, directly below the "Filter tickets…" box (no section header — the chips read on their own). One chip per ticket type the user thinks in: Bug, Ticket, Epic, Initiative. All four ON by default.
## Behaviour
- Single-click a chip → toggle that type in/out of the list.
- Double-click a chip → isolate (solo) that type: turns it ON and all others OFF. Double-click the same chip again → restore all to ON. This is the chart-legend solo pattern (Plotly/Tableau/Grafana) — learnable and fully reversible.
- Last-off resets to all-on: disabling the final remaining type snaps all chips back ON. An empty type filter means "no filter", so the list is never mysteriously blank.
- Tooltip per chip: "Click to toggle · double-click to isolate".
## Type mapping (pql → chip)
pql ticket types are initiative, epic, story, task, bug (see lib/builtin/tickets/src/ticket_colors.dart). The four chips map as:
- Bug → bug
- Ticket → story + task (leaf work items)
- Epic → epic
- Initiative → initiative
Each chip carries its type-colored dot + border using TicketTypeColors (bug #E87D7D, story/task green/grey, epic #78A0F8, initiative #C792EA).
## Filtering
- Type filter is ANDed with the existing text filter in _TicketsViewState (lib/builtin/tickets/src/tickets_view.dart): a ticket shows only if its type is enabled AND it matches the text filter.
- When the type filter hides all items in a status section, that section collapses out (same as text-filter behaviour today).
## Implementation notes
- Active-chip visual: filled tint + type-colored border (active) vs muted/no border (inactive) — reuse the _Toggle pattern from lib/builtin/search/src/search_panel_view.dart and ClideTappable.
- Persist nothing across sessions for v1 (always all-on on load); revisit if requested.
- a11y: Semantics(button, toggled) per chip, mirroring the search-panel toggle.
## Wireframe
docs/design/wireframes/tickets/ticket-type-filters.json (+ .png export)', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 15:54:09', '2026-06-10 16:02:55', NULL, '83c5d82ad411e2827733928cdb81c6e0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4J5E6W983P1S7BE0FDPSMM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Conversation card font sizes inconsistent: ClideCollapserCard vs ConversationCard', 'Two collapsible-card primitives sit adjacent in the Claude conversation stream but use different font-size tokens for the same visual roles, so labels/summaries are visibly 1-2px off between neighbouring cards.
- ClideCollapserCard (lib/widgets/src/clide_collapser_card.dart) — the ''Activity'' / agent-run / edit-run cards: header label, collapsedSummary, and counter all use clideFontCaption (14).
- ConversationCard (lib/builtin/claude/src/conversation_card.dart) — tool-use / result / ''denied'' cards: header label uses clideFontSmall (12), collapsedSummary uses clideFontMeta (13).
Result: an ''Activity Bash …'' collapser and a neighbouring ''Bash · denied'' tool card render their label + collapsed summary at different sizes in the same list. Spotted in a screenshot of three stacked cards (collapser Bash, denied tool result, collapser Read).
Fix: pick one set of tokens for the shared header/label/summary role across both primitives (likely align ConversationCard''s label to clideFontCaption and standardise the collapsed-summary size), or extract a shared card-header style. Tokens: clideFontCaption=14, clideFontMeta=13, clideFontSmall=12 (lib/widgets/src/typography.dart).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 16:06:08', '2026-06-10 16:06:08', NULL, 'c858a9b7c474697611c3ff8240c61a2d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4FDREHRYRR7B9ER72KCQKC', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ticket panel: per-type filter chips (Bug / Ticket / Epic / Initiative)', 'Add a row of type-filter toggle chips at the top of the tickets panel, directly below the "Filter tickets…" box (no section header — the chips read on their own). One chip per ticket type the user thinks in: Bug, Ticket, Epic, Initiative. All four ON by default.
## Behaviour
- Single-click a chip → toggle that type in/out of the list.
- Double-click a chip → isolate (solo) that type: turns it ON and all others OFF. Double-click the same chip again → restore all to ON. This is the chart-legend solo pattern (Plotly/Tableau/Grafana) — learnable and fully reversible.
- Last-off resets to all-on: disabling the final remaining type snaps all chips back ON. An empty type filter means "no filter", so the list is never mysteriously blank.
- Tooltip per chip: "Click to toggle · double-click to isolate".
## Type mapping (pql → chip)
pql ticket types are initiative, epic, story, task, bug (see lib/builtin/tickets/src/ticket_colors.dart). The four chips map as:
- Bug → bug
- Ticket → story + task (leaf work items)
- Epic → epic
- Initiative → initiative
Each chip carries its type-colored dot + border using TicketTypeColors (bug #E87D7D, story/task green/grey, epic #78A0F8, initiative #C792EA).
## Filtering
- Type filter is ANDed with the existing text filter in _TicketsViewState (lib/builtin/tickets/src/tickets_view.dart): a ticket shows only if its type is enabled AND it matches the text filter.
- When the type filter hides all items in a status section, that section collapses out (same as text-filter behaviour today).
## Implementation notes
- Active-chip visual: filled tint + type-colored border (active) vs muted/no border (inactive) — reuse the _Toggle pattern from lib/builtin/search/src/search_panel_view.dart and ClideTappable.
- Persist nothing across sessions for v1 (always all-on on load); revisit if requested.
- a11y: Semantics(button, toggled) per chip, mirroring the search-panel toggle.
## Wireframe
docs/design/wireframes/tickets/ticket-type-filters.json (+ .png export)
Design revision (2026-06-10, supersedes the chip set/order above): FIVE chips, one per pql type — no story+task grouping. Ordered LARGE→SMALL left to right: Initiative, Epic, Story, Task, Bug. Each maps 1:1 to its pql type (initiative/epic/story/task/bug) with its TicketTypeColors dot+border (initiative #C792EA, epic #78A0F8, story #7DD3A8, task #9AA0AA grey, bug #E87D7D). All five ON by default. Toggle/solo(double-click)/last-off-reset behaviour unchanged. Wireframe updated + approved.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 15:54:09', '2026-06-10 16:06:25', NULL, '93554cd5afe5bd881b12bef2892e25b8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5TWC00GW0P3X02HZW', 'epic', NULL, 'UI tweaks & fixes', 'Ongoing umbrella for small, standalone UI polish, cosmetic tweaks, and visual/interaction bug fixes that don''t belong to a feature epic — color/token corrections, control placement, status surfaces, micro-interactions, and the wireframes that frame them. Children are independently shippable; the epic stays open as a rolling home for this class of work.
**PERMANENT — never close.** This is a standing rolling tracker for loose UI/UX work and bugs, not a deliverable epic. It stays open indefinitely; only its children are completed/closed. Do not mark T-276 done even when all current children are closed — new tweaks/fixes get filed here on an ongoing basis.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-08 07:46:23', '2026-06-10 16:09:21', NULL, 'd9e663378cc6deec9bc85f95f5552db8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5TWC00GW0P3X02HZW', 'epic', NULL, 'UI tweaks & fixes', 'Ongoing umbrella for small, standalone UI polish, cosmetic tweaks, and visual/interaction bug fixes that don''t belong to a feature epic — color/token corrections, control placement, status surfaces, micro-interactions, and the wireframes that frame them. Children are independently shippable; the epic stays open as a rolling home for this class of work.
**PERMANENT — never close.** This is a standing rolling tracker for loose UI/UX work and bugs, not a deliverable epic. It stays open indefinitely; only its children are completed/closed. Do not mark T-276 done even when all current children are closed — new tweaks/fixes get filed here on an ongoing basis.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-08 07:46:23', '2026-06-10 16:09:26', NULL, 'fc45344f2d71e9b71573100825c2bce7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5TWC00GW0P3X02HZW', 'epic', NULL, 'UI tweaks & fixes', 'Ongoing umbrella for small, standalone UI polish, cosmetic tweaks, and visual/interaction bug fixes that don''t belong to a feature epic — color/token corrections, control placement, status surfaces, micro-interactions, and the wireframes that frame them. Children are independently shippable; the epic stays open as a rolling home for this class of work.
**PERMANENT — never close.** This is a standing rolling tracker for loose UI/UX work and bugs, not a deliverable epic. It stays open indefinitely; only its children are completed/closed. Do not mark T-276 done even when all current children are closed — new tweaks/fixes get filed here on an ongoing basis.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-08 07:46:23', '2026-06-10 16:09:57', NULL, 'a1ab5ae76714ede6d6a473db9b5d8aa9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4J5E6W983P1S7BE0FDPSMM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Conversation card font sizes inconsistent: ClideCollapserCard vs ConversationCard', 'Two collapsible-card primitives sit adjacent in the Claude conversation stream but use different font-size tokens for the same visual roles, so labels/summaries are visibly 1-2px off between neighbouring cards.
- ClideCollapserCard (lib/widgets/src/clide_collapser_card.dart) — the ''Activity'' / agent-run / edit-run cards: header label, collapsedSummary, and counter all use clideFontCaption (14).
- ConversationCard (lib/builtin/claude/src/conversation_card.dart) — tool-use / result / ''denied'' cards: header label uses clideFontSmall (12), collapsedSummary uses clideFontMeta (13).
Result: an ''Activity Bash …'' collapser and a neighbouring ''Bash · denied'' tool card render their label + collapsed summary at different sizes in the same list. Spotted in a screenshot of three stacked cards (collapser Bash, denied tool result, collapser Read).
Fix: pick one set of tokens for the shared header/label/summary role across both primitives (likely align ConversationCard''s label to clideFontCaption and standardise the collapsed-summary size), or extract a shared card-header style. Tokens: clideFontCaption=14, clideFontMeta=13, clideFontSmall=12 (lib/widgets/src/typography.dart).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 16:06:08', '2026-06-10 16:10:20', NULL, '140190d844d98929ecc83e30cdcc0745', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4J5E6W983P1S7BE0FDPSMM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Conversation card font sizes inconsistent: ClideCollapserCard vs ConversationCard', 'Two collapsible-card primitives sit adjacent in the Claude conversation stream but use different font-size tokens for the same visual roles, so labels/summaries are visibly 1-2px off between neighbouring cards.
- ClideCollapserCard (lib/widgets/src/clide_collapser_card.dart) — the ''Activity'' / agent-run / edit-run cards: header label, collapsedSummary, and counter all use clideFontCaption (14).
- ConversationCard (lib/builtin/claude/src/conversation_card.dart) — tool-use / result / ''denied'' cards: header label uses clideFontSmall (12), collapsedSummary uses clideFontMeta (13).
Result: an ''Activity Bash …'' collapser and a neighbouring ''Bash · denied'' tool card render their label + collapsed summary at different sizes in the same list. Spotted in a screenshot of three stacked cards (collapser Bash, denied tool result, collapser Read).
Fix: pick one set of tokens for the shared header/label/summary role across both primitives (likely align ConversationCard''s label to clideFontCaption and standardise the collapsed-summary size), or extract a shared card-header style. Tokens: clideFontCaption=14, clideFontMeta=13, clideFontSmall=12 (lib/widgets/src/typography.dart).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 16:06:08', '2026-06-10 16:10:22', NULL, 'f02a03592e970322a82bf1be7002b7fa', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4FDREHRYRR7B9ER72KCQKC', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ticket panel: per-type filter chips (Bug / Ticket / Epic / Initiative)', 'Add a row of type-filter toggle chips at the top of the tickets panel, directly below the "Filter tickets…" box (no section header — the chips read on their own). One chip per ticket type the user thinks in: Bug, Ticket, Epic, Initiative. All four ON by default.
## Behaviour
- Single-click a chip → toggle that type in/out of the list.
- Double-click a chip → isolate (solo) that type: turns it ON and all others OFF. Double-click the same chip again → restore all to ON. This is the chart-legend solo pattern (Plotly/Tableau/Grafana) — learnable and fully reversible.
- Last-off resets to all-on: disabling the final remaining type snaps all chips back ON. An empty type filter means "no filter", so the list is never mysteriously blank.
- Tooltip per chip: "Click to toggle · double-click to isolate".
## Type mapping (pql → chip)
pql ticket types are initiative, epic, story, task, bug (see lib/builtin/tickets/src/ticket_colors.dart). The four chips map as:
- Bug → bug
- Ticket → story + task (leaf work items)
- Epic → epic
- Initiative → initiative
Each chip carries its type-colored dot + border using TicketTypeColors (bug #E87D7D, story/task green/grey, epic #78A0F8, initiative #C792EA).
## Filtering
- Type filter is ANDed with the existing text filter in _TicketsViewState (lib/builtin/tickets/src/tickets_view.dart): a ticket shows only if its type is enabled AND it matches the text filter.
- When the type filter hides all items in a status section, that section collapses out (same as text-filter behaviour today).
## Implementation notes
- Active-chip visual: filled tint + type-colored border (active) vs muted/no border (inactive) — reuse the _Toggle pattern from lib/builtin/search/src/search_panel_view.dart and ClideTappable.
- Persist nothing across sessions for v1 (always all-on on load); revisit if requested.
- a11y: Semantics(button, toggled) per chip, mirroring the search-panel toggle.
## Wireframe
docs/design/wireframes/tickets/ticket-type-filters.json (+ .png export)
Design revision (2026-06-10, supersedes the chip set/order above): FIVE chips, one per pql type — no story+task grouping. Ordered LARGE→SMALL left to right: Initiative, Epic, Story, Task, Bug. Each maps 1:1 to its pql type (initiative/epic/story/task/bug) with its TicketTypeColors dot+border (initiative #C792EA, epic #78A0F8, story #7DD3A8, task #9AA0AA grey, bug #E87D7D). All five ON by default. Toggle/solo(double-click)/last-off-reset behaviour unchanged. Wireframe updated + approved.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 15:54:09', '2026-06-10 16:12:15', NULL, '3dd40475245455459d9f09d26ef34316', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4J5E6W983P1S7BE0FDPSMM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Conversation card font sizes inconsistent: ClideCollapserCard vs ConversationCard', 'Two collapsible-card primitives sit adjacent in the Claude conversation stream but use different font-size tokens for the same visual roles, so labels/summaries are visibly 1-2px off between neighbouring cards.
- ClideCollapserCard (lib/widgets/src/clide_collapser_card.dart) — the ''Activity'' / agent-run / edit-run cards: header label, collapsedSummary, and counter all use clideFontCaption (14).
- ConversationCard (lib/builtin/claude/src/conversation_card.dart) — tool-use / result / ''denied'' cards: header label uses clideFontSmall (12), collapsedSummary uses clideFontMeta (13).
Result: an ''Activity Bash …'' collapser and a neighbouring ''Bash · denied'' tool card render their label + collapsed summary at different sizes in the same list. Spotted in a screenshot of three stacked cards (collapser Bash, denied tool result, collapser Read).
Fix: pick one set of tokens for the shared header/label/summary role across both primitives (likely align ConversationCard''s label to clideFontCaption and standardise the collapsed-summary size), or extract a shared card-header style. Tokens: clideFontCaption=14, clideFontMeta=13, clideFontSmall=12 (lib/widgets/src/typography.dart).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 16:06:08', '2026-06-10 16:13:50', NULL, '10a7749a97c64f223054a8d582eead3d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4J5E6W983P1S7BE0FDPSMM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Conversation card font sizes inconsistent: ClideCollapserCard vs ConversationCard', 'Two collapsible-card primitives sit adjacent in the Claude conversation stream but use different font-size tokens for the same visual roles, so labels/summaries are visibly 1-2px off between neighbouring cards.
- ClideCollapserCard (lib/widgets/src/clide_collapser_card.dart) — the ''Activity'' / agent-run / edit-run cards: header label, collapsedSummary, and counter all use clideFontCaption (14).
- ConversationCard (lib/builtin/claude/src/conversation_card.dart) — tool-use / result / ''denied'' cards: header label uses clideFontSmall (12), collapsedSummary uses clideFontMeta (13).
Result: an ''Activity Bash …'' collapser and a neighbouring ''Bash · denied'' tool card render their label + collapsed summary at different sizes in the same list. Spotted in a screenshot of three stacked cards (collapser Bash, denied tool result, collapser Read).
Fix: pick one set of tokens for the shared header/label/summary role across both primitives (likely align ConversationCard''s label to clideFontCaption and standardise the collapsed-summary size), or extract a shared card-header style. Tokens: clideFontCaption=14, clideFontMeta=13, clideFontSmall=12 (lib/widgets/src/typography.dart).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 16:06:08', '2026-06-10 16:15:38', NULL, 'e5e9f526180b6e48628629c2c3c5138b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB493JEW32CH0H3771TNHF7G', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Give each spawned subagent its own collapsing activity card (don''t merge Task spawns)', 'Filed 2026-06-10 from a user request: when the agent fans out multiple subagents (Task/Agent tool), each spawned subagent should get its OWN collapsing activity card. The space is worth it — a fan-out of N agents should read as N cards, not one lumped card.
CURRENT BEHAVIOUR (confirmed): multiple Task/Agent spawns are MERGED into a single shared ''Activity / N steps'' cluster. groupConversation() in lib/builtin/claude/src/activity_cluster.dart:120-147 walks items and coalesces every consecutive _isFoldable item into one FoldedCluster. _isFoldable (:149-172, AssistantToolUse case at :161-164) only distinguishes diff tools (Edit/Write/MultiEdit/NotebookEdit/Update -> stay first-class) from everything else (Task/Agent/Bash/Read/... -> all foldable). The Task tool is treated identically to a Bash/Read call; there is NO subagent-aware grouping key (not toolUseId, not parent_tool_use_id). So 4 spawned agents render as one ''Activity 4 steps'' card.
What recent work already does (do NOT redo): T-263 folds the subagent PROMPT into the Agent card; T-264 nests the subagent''s RUN items under the parent Agent card (the ''agent run'' collapser); T-338 routes sidechain items to their parent via parent_tool_use_id. All of that is about what shows INSIDE one agent''s card. This ticket is the complement: stop merging DISTINCT agent spawns into a shared cluster.
SCOPE / DESIGN:
- An AssistantToolUse where _isAgentTool(name) (Task/Agent) should break the current Activity cluster and render as its own first-class collapsing card (its own ClideCollapserCard with the prompt + nested ''agent run'' from T-263/T-264), rather than folding into the generic Activity cluster with sibling tool calls.
- Decide grouping precisely in groupConversation/_isFoldable: an Agent tool-use is a cluster boundary (like a sticky item) OR emits its own single-item card. Adjacent non-agent foldables (Bash/Read/Grep) keep clustering into the normal Activity card as today.
- Label each subagent card by its task/description (the Agent call''s label) so parallel fan-outs are distinguishable, not ''Activity N steps''.
- Keep collapsed-by-default behaviour and the FoldLevel semantics; this changes the grouping boundary, not the fold mechanics.
Refs: lib/builtin/claude/src/activity_cluster.dart (groupConversation, _isFoldable, isDiffTool), lib/builtin/claude/src/conversation_view.dart (_ActivityCard at ~833, _toolUseCollapser ~615-657, _isAgentTool ~378). Related: T-230 (clustering), T-263, T-264, T-338.
Tests: a groupConversation case asserting that two consecutive Agent tool-uses yield two separate cards (not one FoldedCluster), while two consecutive Bash calls still yield one Activity cluster.
SCOPE CLARIFICATION (2026-06-10, from user):
1. RIGHT card, not just A card. Each per-agent card must pull in ALL of that agent''s nested run — the folded prompt (T-263) AND every nested response: prose, thinking, sidechain tool cards, and results (T-264) — attributed to the CORRECT agent even under a parallel fan-out where multiple agents'' sidechain items interleave in the stream. Reuse the existing _sidechainFold machinery (conversation_view.dart:188-256): runByToolUseId / promptsByToolUseId are already keyed by the owning Agent''s toolUseId, and resolveOwner''s direct route (conversation_view.dart:210-217) uses parent_tool_use_id (T-338) which disambiguates concurrent agents correctly. THE HAZARD: resolveOwner falls back to ''nearest'' = lastAgent (the most-recently-emitted Agent in stream order; conversation_view.dart:228, applied at :242). In a parallel fan-out any item that lacks parent_tool_use_id and a rooted parentUuid chain would mis-route to whichever agent was emitted last — landing in the WRONG card. Harden this: for the multi-agent case, drop or guard the nearest-lastAgent fallback so an unattributable item is rendered inline/orphaned (resolveOwner already returns null -> handled at :243) rather than mis-filed into a sibling agent''s card.
2. PRESERVE the existing grouping. This ticket only adds an Agent-spawn cluster boundary; it must NOT regress the rest:
- Non-agent foldables (Bash/Read/Grep/LS/etc.) keep coalescing into the generic ''Activity / N steps'' cluster exactly as today (activity_cluster.dart groupConversation/_isFoldable).
- The intra-agent folding stays: prompt-into-call (T-263), run-nested-under-card (T-264), sidechain routing by parent_tool_use_id (T-338). Reuse them; do not rebuild.
- Net behaviour: a fan-out of N agents -> N distinct collapsed cards, each containing its own complete run; surrounding non-agent tool calls still group into their normal Activity card.
Test additions: (a) two concurrent agents whose sidechain items interleave -> each agent''s run items land under its own card, none cross-attributed; (b) an unattributable sidechain item (no parent_tool_use_id, broken chain) is NOT swept into the nearest agent''s card; (c) regression: consecutive Bash/Read calls still form one Activity cluster.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 15:26:33', '2026-06-10 16:21:55', NULL, 'dcbfa661a4a0238a2578ee8ed800444b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB44SKPKTHFMV6WD28GZYPXM', 'task', NULL, 'Support double-tap / bare-modifier chords (JetBrains Search Everywhere)', 'The KeyChord matcher can''t represent a bare or double-tapped modifier: KeyChord.fromKeyEvent returns null for bare modifier presses (key_chord.dart _isBareModifier), and KeyChord.parse requires a base key after the last ''+'', so ''shift+shift'' / ''shift shift'' both fail. Consequence: JetBrains ''Search Everywhere'' (double-Shift) and any double-tap-modifier shortcut can''t be bound.
Decide (design): is search-everywhere its own intent + overlay, or an alias of quick-open? Then extend the chord/sequence matcher to represent a bare-modifier ''key'' (and a double-tap as a sequence) so a preset can bind it.
Surfaced by T-64/T-66 (VS Code / JetBrains keymap presets). Those tickets'' ''see Q-9'' reference is STALE — Q-9 is ''Lua runtime vendoring'', unrelated. The JetBrains preset currently maps quick-open to Ctrl+Shift+N (Go to File) and the palette to Ctrl+Shift+A (Find Action) as the expressible IntelliJ equivalents.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 15:07:43', '2026-06-10 16:22:24', NULL, '1ecfc619afc1c684d10ccce3f2ad2a72', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'ExitPlanMode does not exit plan mode in the conversation panel', 'Observed live: Claude used the ExitPlanMode tool (approved the plan / left plan mode), but the clide conversation panel did not reflect the exit — it still appears to be in plan mode afterward.
Expected: once ExitPlanMode is accepted, the pane leaves plan mode — the permission-mode indicator (claude_status / permission_mode_control) flips off ''plan'', and the composer / status reflect the new mode.
Where to look:
- How the stream-json session handles the ExitPlanMode tool/control event (stream_json_session.dart) and whether it updates the tracked permission mode (claude_status.dart permissionMode).
- The permission-mode control + status badge wiring (permission_mode_control.dart, claude_pane.dart) — does an ExitPlanMode resolution feed back into the mode the way Ctrl/Cmd+M and the picker do?
- Whether ExitPlanMode arrives as a permission prompt (ToolPromptCard) and, if approved, actually transitions the session out of plan mode rather than just resolving the tool.
Repro: enter plan mode, let Claude present a plan via ExitPlanMode, accept it — the pane stays showing plan mode.
Acceptance: accepting ExitPlanMode leaves plan mode in the conversation panel (mode indicator + behavior update), matching what the underlying session does.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 13:27:36', '2026-06-10 16:22:28', NULL, 'd87a50140fc2477aad77f2bf10c64da9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2T11GCV1EV07DYD5BZENTM', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Live tail sub-card inside expanded Bash activity cards', 'Expanded Bash activity cards should be able to show a live, scrolling tail of a file-backed source the command is following, so the user can watch the same output Claude is tailing — connected lazily, only while the card is expanded.
**Motivation**
Long-running / tailing Bash steps render only a final result block once the command finishes (see screenshot in T-304 thread). The user can''t follow progress. We want an embedded scrolling terminal sub-card inside the expanded activity card for visibility.
**Hard constraint (feasibility, established by investigation)**
Claude Code CLI executes every Bash tool itself; clide only ingests stream-json events (stream_json_session.dart -> parseTranscriptChunk). A tool_result arrives as ONE complete block — clide never sees in-flight tool output, and the controller''s in-place upsert path is hardcoded for `partial-<msg_id>` prose deltas, not tool results (controller.dart). We cannot tap Claude''s subprocess fd. So a fully-general "mirror the running command''s stdout" is NOT possible.
**Viable scope: independent file-tail on expand**
- On card expand, parse the Bash command string (AssistantToolUse.input["command"], transcript_reader.dart) for a file-backed source: `tail -f X`, `tail -n N X`, `cat X`, `less X`, known log paths, or a background task writing to a file.
- If found, clide opens its OWN read-only follower — a NativePty running `tail -f X` (native_pty.dart already exposes Stream<Uint8List>) or a Dart file-watcher — and renders it in an embedded scrolling terminal sub-card, reusing TerminalView.
- Lazy lifecycle: connect on expand, disconnect/dispose on collapse. No connection until expanded.
- Read-only & safe: clide observes the same file; it never re-runs or intercepts Claude''s command.
- Graceful when there is no file-backed source (e.g. `git push ... | tail -25`, a pipe inside Claude''s process): show a muted "no independent source to follow" note rather than faking output.
**Where**
- Card body: conversation_card.dart / conversation_view.dart (_toolUseCollapser, the Bash tool card). Add an optional live-tail segment below the RESULT segment.
- Key the live connection on AssistantToolUse.toolUseId.
- Embedded terminal: reuse lib/src/terminal TerminalView + lib/src/pty NativePty.
**Open questions (settle in review / may need a Q-record)**
- Command-parsing surface: which commands/forms count as "tailable"? Keep it a small, explicit allowlist to avoid mis-following.
- Security: only follow paths inside the workspace? How to handle absolute paths outside the repo.
- Lifecycle when the underlying command has already finished (file static) vs still running.
**Acceptance**
- Expanding a Bash card whose command tails a file shows a live scrolling terminal sub-card that updates as the file grows; collapsing it tears the follower down.
- A Bash card with no file-backed source shows a clear "nothing to follow" affordance, never a broken/empty terminal.
- No follower process is spawned until the card is expanded.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 12:00:52', '2026-06-10 16:22:37', NULL, '031ad585f4bea545d6efa0ead8c3830e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4RD1DDSYM4J7WYEGTXARB4', 'task', NULL, 'Make the coverage harness concurrency-safe (per-run paths)', 'ci/test.sh --coverage runs two flutter test --coverage passes that both write the fixed coverage/lcov.info, copies it to coverage/lcov.parallel.info, merges, then rm -f''s the intermediate. Any concurrent flutter test --coverage (a second push gate, or a ''make test'' during a push) races on those shared paths — the second run''s rm -f deletes lcov.parallel.info mid-merge, crashing merge_lcov.py with FileNotFoundError (observed during a push, 2026-06-10).
Fix: give each run its own coverage output via flutter test --coverage-path <tmp>, with a per-run mktemp dir for the two passes + the merge; only the final merged result lands in coverage/lcov.info via an atomic rename within coverage/. No shared intermediate paths, so concurrent runs can''t corrupt each other.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 16:33:23', '2026-06-10 16:33:23', NULL, '9b0de784a50a9ba162dc46fc477ca6b5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4RD1DDSYM4J7WYEGTXARB4', 'task', NULL, 'Make the coverage harness concurrency-safe (per-run paths)', 'ci/test.sh --coverage runs two flutter test --coverage passes that both write the fixed coverage/lcov.info, copies it to coverage/lcov.parallel.info, merges, then rm -f''s the intermediate. Any concurrent flutter test --coverage (a second push gate, or a ''make test'' during a push) races on those shared paths — the second run''s rm -f deletes lcov.parallel.info mid-merge, crashing merge_lcov.py with FileNotFoundError (observed during a push, 2026-06-10).
Fix: give each run its own coverage output via flutter test --coverage-path <tmp>, with a per-run mktemp dir for the two passes + the merge; only the final merged result lands in coverage/lcov.info via an atomic rename within coverage/. No shared intermediate paths, so concurrent runs can''t corrupt each other.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 16:33:23', '2026-06-10 16:34:51', NULL, '75938b68f10edf857e25100141d08544', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4VG3N3YJSV8G7M1HFSYW2W', 'task', NULL, 'Profile and speed up the push-check gate', 'The push gate (make push-check) is dominated by ''flutter test --coverage'' (coverage instrumentation ~doubles flutter-test wall-time vs the ~20s no-coverage ''make test''). Recent slowness was traced to concurrent-session contention (fixed by T-345), not suite bloat — but there is real speedup headroom.
Findings (2026-06-10):
- 245 test files; the 39 Flutter-free core files (test/ipc, daemon, git, panes, files, editor, pql) run TWICE in the gate: under flutter test --coverage (for the coverage floor) AND under dart test (test-core, the Flutter-free contract guard). Each serves a purpose but it is the clearest redundancy.
- 33 test files use pumpAndSettle (ci/test.sh''s own comment warns against it; prefer the pumpAsync helper) — a common wall-time/hang sink.
- 47 ''Duration(seconds:)'' occurrences in tests; some are legit timeouts, some are real-time waits that add wall-clock.
Scope:
1. Profile: one timed run (JSON reporter / --reporter) to rank the ~20 slowest tests; the pumpAndSettle + Duration(seconds:) files are prime suspects.
2. Fix the genuinely slow ones (bounded pumps, fake-async, no real sleeps) per feedback_tests_run_light + feedback_runasync_for_io.
3. Investigate collapsing the core double-run — collect coverage from the dart test core pass and drop those 39 files from the flutter --coverage pass — only if the profile shows that slice is large enough to justify the refactor.
Do NOT cut tests for speed: 245 files for a public tool with a 95% floor is comprehensive, not over-tested. Make slow tests fast instead.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 16:46:54', '2026-06-10 16:46:54', NULL, '481a097650e0b1badea0b24cdf48afda', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4XCM5KBXDDSCWJ37GPYG3R', 'bug', NULL, 'pql pane fails in desktop-launched clide: PATH not expanded on Linux', 'The pql sidebar pane (and any PATH-resolved tool) fails when clide is launched from a desktop launcher rather than a terminal. lib/kernel/src/toolchain_paths.dart _expandedPath() augments PATH with ~/.local/bin + /usr/local/bin etc. ONLY on macOS (line: ''if (!Platform.isMacOS) return base;''). On Linux it uses the raw inherited PATH.
A GUI/desktop-launched app inherits a minimal PATH (e.g. /usr/bin:/bin) with no ~/.local/bin, so _findOnPath(''pql'') returns null, clide falls back to spawning the literal ''pql'', and Process.start fails with ENOENT — the pql pane errors. pql is installed to ~/.local/bin. Reproduced: ''env -i PATH=/usr/bin:/bin command -v pql'' → not found. Works under ''make run'' only because the terminal PATH includes ~/.local/bin.
Fix: extend _expandedPath() to also augment on Linux — prepend ~/.local/bin and /usr/local/bin (keep the homebrew dirs macOS-only). Affects pql, tmux, and PATH-resolved git alike. User running the installed build needs a rebuild+reinstall after the fix; immediate workaround is launching clide from a terminal.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 16:55:10', '2026-06-10 16:55:10', NULL, '4a647a5c33b044df6ace163651fc8e93', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4XCM5KBXDDSCWJ37GPYG3R', 'bug', NULL, 'pql pane fails in desktop-launched clide: PATH not expanded on Linux', 'The pql sidebar pane (and any PATH-resolved tool) fails when clide is launched from a desktop launcher rather than a terminal. lib/kernel/src/toolchain_paths.dart _expandedPath() augments PATH with ~/.local/bin + /usr/local/bin etc. ONLY on macOS (line: ''if (!Platform.isMacOS) return base;''). On Linux it uses the raw inherited PATH.
A GUI/desktop-launched app inherits a minimal PATH (e.g. /usr/bin:/bin) with no ~/.local/bin, so _findOnPath(''pql'') returns null, clide falls back to spawning the literal ''pql'', and Process.start fails with ENOENT — the pql pane errors. pql is installed to ~/.local/bin. Reproduced: ''env -i PATH=/usr/bin:/bin command -v pql'' → not found. Works under ''make run'' only because the terminal PATH includes ~/.local/bin.
Fix: extend _expandedPath() to also augment on Linux — prepend ~/.local/bin and /usr/local/bin (keep the homebrew dirs macOS-only). Affects pql, tmux, and PATH-resolved git alike. User running the installed build needs a rebuild+reinstall after the fix; immediate workaround is launching clide from a terminal.', 'done', 'high', NULL, NULL, NULL, '2026-06-10 16:55:10', '2026-06-10 16:57:07', NULL, '98d93fef85789907f6bf6efc52a3fcdb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB50YE6S6YWNP2ZSFWES9B2W', 'task', NULL, 'Pre-push gate: skip the test suite for docs/changelog/pql/meta-only pushes', 'The .githooks/pre-push hook runs ''make push-check'' unconditionally — the full ~2min coverage + test-core suite even when a push touches only documentation, the changelog, pql ticket data, or other meta files that cannot affect the Flutter/Dart build or tests.
Make the hook path-aware: diff the commits being pushed (remote_sha..local_sha from the hook''s stdin). If every changed file is under an allowlist that can''t affect tests — docs/, governance/, .pql/, .claude/, CHANGELOG.md, README.md, CLAUDE.md — run only the instant gates (decisions-validate + changelog-gate) and skip test-coverage/coverage-gate/test-core. Any code-ish path (lib/, test/, assets/, pubspec.*, native/, ci/, .githooks/, Makefile, platform dirs) → full gate. Fail safe: unknown/new-branch states run the full gate.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 17:10:42', '2026-06-10 17:10:42', NULL, 'edb52a1ac5c6a0e01ebaaf5ceff5e96c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB50YE6S6YWNP2ZSFWES9B2W', 'task', NULL, 'Pre-push gate: skip the test suite for docs/changelog/pql/meta-only pushes', 'The .githooks/pre-push hook runs ''make push-check'' unconditionally — the full ~2min coverage + test-core suite even when a push touches only documentation, the changelog, pql ticket data, or other meta files that cannot affect the Flutter/Dart build or tests.
Make the hook path-aware: diff the commits being pushed (remote_sha..local_sha from the hook''s stdin). If every changed file is under an allowlist that can''t affect tests — docs/, governance/, .pql/, .claude/, CHANGELOG.md, README.md, CLAUDE.md — run only the instant gates (decisions-validate + changelog-gate) and skip test-coverage/coverage-gate/test-core. Any code-ish path (lib/, test/, assets/, pubspec.*, native/, ci/, .githooks/, Makefile, platform dirs) → full gate. Fail safe: unknown/new-branch states run the full gate.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 17:10:42', '2026-06-10 17:13:53', NULL, 'f846b01b35bda1b8e247f360c46d472f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB58X0TFJ02YTMVPD0D9Q838', 'bug', NULL, 'Linux build silently drops frameless chrome when wayland-client missing', 'linux/runner/CMakeLists.txt does pkg_check_modules(WAYLAND_CLIENT IMPORTED_TARGET wayland-client) WITHOUT REQUIRED, then only links it + defines HAS_WAYLAND_CLIENT inside if(WAYLAND_CLIENT_FOUND). The entire frameless custom-chrome path (D-057, KDE server-decoration protocol in clide_app.cc) is #ifdef HAS_WAYLAND_CLIENT.
So a build host/container missing the Wayland client dev headers (very plausible in a Bazzite/immutable distrobox or toolbox) silently compiles out the decoration suppression — the rebuilt app ships with the compositor''s native title bar (double title bar on KDE Plasma Wayland). Reported live on Bazzite KDE.
Fix: make wayland-client a hard build requirement — fail the CMake configure with a clear, actionable message (name the package: Fedora wayland-devel, Debian/Ubuntu libwayland-dev) instead of dropping the feature. Frameless chrome is a core guardrail; never ship without it. Also fix the stale ''xdg-decoration'' comment (the code uses the KDE server-decoration protocol, not xdg-decoration).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 17:45:28', '2026-06-10 17:45:28', NULL, 'a60bee185c5bae1a8bf8bca98303883f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB58X0TFJ02YTMVPD0D9Q838', 'bug', NULL, 'Linux build silently drops frameless chrome when wayland-client missing', 'linux/runner/CMakeLists.txt does pkg_check_modules(WAYLAND_CLIENT IMPORTED_TARGET wayland-client) WITHOUT REQUIRED, then only links it + defines HAS_WAYLAND_CLIENT inside if(WAYLAND_CLIENT_FOUND). The entire frameless custom-chrome path (D-057, KDE server-decoration protocol in clide_app.cc) is #ifdef HAS_WAYLAND_CLIENT.
So a build host/container missing the Wayland client dev headers (very plausible in a Bazzite/immutable distrobox or toolbox) silently compiles out the decoration suppression — the rebuilt app ships with the compositor''s native title bar (double title bar on KDE Plasma Wayland). Reported live on Bazzite KDE.
Fix: make wayland-client a hard build requirement — fail the CMake configure with a clear, actionable message (name the package: Fedora wayland-devel, Debian/Ubuntu libwayland-dev) instead of dropping the feature. Frameless chrome is a core guardrail; never ship without it. Also fix the stale ''xdg-decoration'' comment (the code uses the KDE server-decoration protocol, not xdg-decoration).', 'done', 'high', NULL, NULL, NULL, '2026-06-10 17:45:28', '2026-06-10 17:47:01', NULL, '6eb6120c0ef64a7f5ce78073b398143c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5CW7JPT6BR2RWMNYVCXJ50', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'pql sidebar panes stick on error when the first fetch fires too early', 'The pql-backed sidebar panes (tickets, decisions, pql, search) fetch once on first build via pql.* IPC. If that first fetch hits a TRANSIENT pql failure — the planning DB still settling at startup, or a db-busy SQLite lock under concurrent pql writes (pql 1.10 exits 69 ''db busy'', as seen in the serial test suite) — the pane shows ''pql ticket failed'' and stays there until a manual refresh / tab-switch re-fires the fetch. Reproduced live on 2.3.0: left pane ''pql ticket failed''; works on manual refresh. pql works fine in isolation, so it''s purely a too-early / transient timing issue with no retry.
Fix: make pql invocations resilient to transient failures at the single chokepoint, PqlClient._run (lib/src/pql/client.dart) — on a busy/locked signal (exit 69, or stderr mentioning database is locked / busy), retry a small bounded number of times with short backoff before throwing PqlException. Keep genuine errors fast (don''t blanket-retry every non-zero). Fixes all pql panes at once.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 18:02:50', '2026-06-10 18:02:50', NULL, 'd03c864f5e64284f3582e59556a4ab4f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5CW7JPT6BR2RWMNYVCXJ50', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'pql sidebar panes stick on error when the first fetch fires too early', 'The pql-backed sidebar panes (tickets, decisions, pql, search) fetch once on first build via pql.* IPC. If that first fetch hits a TRANSIENT pql failure — the planning DB still settling at startup, or a db-busy SQLite lock under concurrent pql writes (pql 1.10 exits 69 ''db busy'', as seen in the serial test suite) — the pane shows ''pql ticket failed'' and stays there until a manual refresh / tab-switch re-fires the fetch. Reproduced live on 2.3.0: left pane ''pql ticket failed''; works on manual refresh. pql works fine in isolation, so it''s purely a too-early / transient timing issue with no retry.
Fix: make pql invocations resilient to transient failures at the single chokepoint, PqlClient._run (lib/src/pql/client.dart) — on a busy/locked signal (exit 69, or stderr mentioning database is locked / busy), retry a small bounded number of times with short backoff before throwing PqlException. Keep genuine errors fast (don''t blanket-retry every non-zero). Fixes all pql panes at once.', 'done', 'high', NULL, NULL, NULL, '2026-06-10 18:02:50', '2026-06-10 18:20:28', NULL, 'd48c86ad83f3da7cd4f8c9308c852448', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'bug', NULL, 'Frameless chrome: KWin keeps the title bar — decoration request fires before the wl_surface exists', 'On KDE Plasma 6 / KWin 6 (Wayland), clide still shows the compositor''s native title bar even though the frameless code (D-057, KDE server-decoration protocol) is compiled in (confirmed: with T-349 making wayland-client REQUIRED, a 2.3.0 build that runs at all has it).
Root cause is timing, not the protocol. linux/runner/clide_app.cc connects request_no_server_decorations() to the GtkWidget ''realize'' signal, but GTK''s Wayland backend only creates the wl_surface on MAP, not realize. So gdk_wayland_window_get_wl_surface() returns null and the function bails at its own ''if (surface == nullptr) return'' before creating the org_kde_kwin_server_decoration / requesting mode NONE. KWin 6 defaults to server-side decorations on Wayland unless that request lands → native title bar shows (double title bar with clide''s own chrome).
Fix: also fire the request on the ''map'' signal (wl_surface is live by then); the realize handler still does the X11 gdk_window_set_decorations hint and harmlessly bails on the Wayland part (surface null) so no duplicate decoration object is created. If KWin 6 turns out not to honor the legacy KDE protocol, fall back to the standard xdg-decoration protocol (zxdg_decoration_manager_v1, set_mode CLIENT_SIDE).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 18:23:41', '2026-06-10 18:23:41', NULL, 'ec216013c0bb79f1f11a87979c9fb886', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'bug', NULL, 'Frameless chrome: KWin keeps the title bar — decoration request fires before the wl_surface exists', 'On KDE Plasma 6 / KWin 6 (Wayland), clide still shows the compositor''s native title bar even though the frameless code (D-057, KDE server-decoration protocol) is compiled in (confirmed: with T-349 making wayland-client REQUIRED, a 2.3.0 build that runs at all has it).
Root cause is timing, not the protocol. linux/runner/clide_app.cc connects request_no_server_decorations() to the GtkWidget ''realize'' signal, but GTK''s Wayland backend only creates the wl_surface on MAP, not realize. So gdk_wayland_window_get_wl_surface() returns null and the function bails at its own ''if (surface == nullptr) return'' before creating the org_kde_kwin_server_decoration / requesting mode NONE. KWin 6 defaults to server-side decorations on Wayland unless that request lands → native title bar shows (double title bar with clide''s own chrome).
Fix: also fire the request on the ''map'' signal (wl_surface is live by then); the realize handler still does the X11 gdk_window_set_decorations hint and harmlessly bails on the Wayland part (surface null) so no duplicate decoration object is created. If KWin 6 turns out not to honor the legacy KDE protocol, fall back to the standard xdg-decoration protocol (zxdg_decoration_manager_v1, set_mode CLIENT_SIDE).', 'review', 'high', NULL, NULL, NULL, '2026-06-10 18:23:41', '2026-06-10 18:24:41', NULL, '0d06f87ff4d71e2ee4e4821968b5e3df', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'bug', NULL, 'Frameless chrome: KWin keeps the title bar — decoration request fires before the wl_surface exists', 'On KDE Plasma 6 / KWin 6 (Wayland), clide still shows the compositor''s native title bar even though the frameless code (D-057, KDE server-decoration protocol) is compiled in (confirmed: with T-349 making wayland-client REQUIRED, a 2.3.0 build that runs at all has it).
Root cause is timing, not the protocol. linux/runner/clide_app.cc connects request_no_server_decorations() to the GtkWidget ''realize'' signal, but GTK''s Wayland backend only creates the wl_surface on MAP, not realize. So gdk_wayland_window_get_wl_surface() returns null and the function bails at its own ''if (surface == nullptr) return'' before creating the org_kde_kwin_server_decoration / requesting mode NONE. KWin 6 defaults to server-side decorations on Wayland unless that request lands → native title bar shows (double title bar with clide''s own chrome).
Fix: also fire the request on the ''map'' signal (wl_surface is live by then); the realize handler still does the X11 gdk_window_set_decorations hint and harmlessly bails on the Wayland part (surface null) so no duplicate decoration object is created. If KWin 6 turns out not to honor the legacy KDE protocol, fall back to the standard xdg-decoration protocol (zxdg_decoration_manager_v1, set_mode CLIENT_SIDE).', 'done', 'high', NULL, NULL, NULL, '2026-06-10 18:23:41', '2026-06-10 18:27:58', NULL, 'e61eeca3f6ccf42a334867c757fda759', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5M14B76B31654D959XM5AC', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ticket sidebar errors on first load: pql runs before the workspace workDir is set', 'The tickets sidebar (and other pql panes) fail on first load when clide is desktop-launched: the daemon''s PqlClient is constructed with workDir = the boot CWD (the launch dir, e.g. ~), not the repo. The pane''s first pql.tickets.list fires before swapIpcServer reconfigures the dispatcher with the project''s workRoot, so pql runs in the wrong dir — against a stale/global pql.db — and errors (observed: ''ticket_deps.blocker_record_id missing — pql.db is from an earlier schema''). A manual refresh works because by then the workspace is open and the workDir is correct.
This is a wrong-workDir timing issue, not db-busy (so the T-350 retry doesn''t catch it). Fix: the pql-backed panes refetch on ProjectOpened (which fires after the IPC server swaps to the project workRoot). Implemented for the tickets pane; the decisions/pql/search panes share the latent bug and should get the same refetch.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 18:34:05', '2026-06-10 18:34:05', NULL, 'a6d1821c06dbccb30c9f7e777aef7ef5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5M14B76B31654D959XM5AC', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ticket sidebar errors on first load: pql runs before the workspace workDir is set', 'The tickets sidebar (and other pql panes) fail on first load when clide is desktop-launched: the daemon''s PqlClient is constructed with workDir = the boot CWD (the launch dir, e.g. ~), not the repo. The pane''s first pql.tickets.list fires before swapIpcServer reconfigures the dispatcher with the project''s workRoot, so pql runs in the wrong dir — against a stale/global pql.db — and errors (observed: ''ticket_deps.blocker_record_id missing — pql.db is from an earlier schema''). A manual refresh works because by then the workspace is open and the workDir is correct.
This is a wrong-workDir timing issue, not db-busy (so the T-350 retry doesn''t catch it). Fix: the pql-backed panes refetch on ProjectOpened (which fires after the IPC server swaps to the project workRoot). Implemented for the tickets pane; the decisions/pql/search panes share the latent bug and should get the same refetch.', 'done', 'high', NULL, NULL, NULL, '2026-06-10 18:34:05', '2026-06-10 18:38:53', NULL, '3891f832c1d930d8fefca2fae7ffea86', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 06:50:21', NULL, 'd5de0d7f9b1c365f7a35c6d0aefa178e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.
FOLLOW-UP SCOPE (folded in 2026-06-11):
1. DONE: osv-scanner supply-chain gate added to `make push-check` (ci/osv_scan.sh; fail-closed on any pubspec.lock advisory). Requires osv-scanner on PATH (brew install osv-scanner).
2. TODO — tighten env floors to reality. pubspec.yaml `environment` currently declares Dart >=3.5.0 / Flutter >=3.19.0, but we actually require more (alchemist 0.12 needs Flutter 3.32; the held markdown 7.3.1 needs Dart 3.9). Raise floors to ~Dart >=3.9.0 / Flutter >=3.32.0 — honest minimums. Raising Dart to 3.9 also unblocks the held markdown 7.2.2 -> 7.3.1 bump.
3. TODO — exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 07:05:54', NULL, 'd81c595ab22359cc6c75d490d17d8c5d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.
FOLLOW-UP SCOPE (folded in 2026-06-11):
1. DONE: osv-scanner supply-chain gate added to `make push-check` (ci/osv_scan.sh; fail-closed on any pubspec.lock advisory). Requires osv-scanner on PATH (brew install osv-scanner).
2. TODO — tighten env floors to reality. pubspec.yaml `environment` currently declares Dart >=3.5.0 / Flutter >=3.19.0, but we actually require more (alchemist 0.12 needs Flutter 3.32; the held markdown 7.3.1 needs Dart 3.9). Raise floors to ~Dart >=3.9.0 / Flutter >=3.32.0 — honest minimums. Raising Dart to 3.9 also unblocks the held markdown 7.2.2 -> 7.3.1 bump.
3. TODO — exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 07:05:58', NULL, '352f3ceb8714f14bd12515a9b4542794', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.
FOLLOW-UP SCOPE (folded in 2026-06-11):
1. DONE: osv-scanner supply-chain gate added to `make push-check` (ci/osv_scan.sh; fail-closed on any pubspec.lock advisory). Requires osv-scanner on PATH (brew install osv-scanner).
2. TODO — tighten env floors to reality. pubspec.yaml `environment` currently declares Dart >=3.5.0 / Flutter >=3.19.0, but we actually require more (alchemist 0.12 needs Flutter 3.32; the held markdown 7.3.1 needs Dart 3.9). Raise floors to ~Dart >=3.9.0 / Flutter >=3.32.0 — honest minimums. Raising Dart to 3.9 also unblocks the held markdown 7.2.2 -> 7.3.1 bump.
3. TODO — exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 10:12:25', NULL, '4738f0609647b88e169e6e5788ded217', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB44SKPKTHFMV6WD28GZYPXM', 'task', NULL, 'Support double-tap / bare-modifier chords (JetBrains Search Everywhere)', 'The KeyChord matcher can''t represent a bare or double-tapped modifier: KeyChord.fromKeyEvent returns null for bare modifier presses (key_chord.dart _isBareModifier), and KeyChord.parse requires a base key after the last ''+'', so ''shift+shift'' / ''shift shift'' both fail. Consequence: JetBrains ''Search Everywhere'' (double-Shift) and any double-tap-modifier shortcut can''t be bound.
Decide (design): is search-everywhere its own intent + overlay, or an alias of quick-open? Then extend the chord/sequence matcher to represent a bare-modifier ''key'' (and a double-tap as a sequence) so a preset can bind it.
Surfaced by T-64/T-66 (VS Code / JetBrains keymap presets). Those tickets'' ''see Q-9'' reference is STALE — Q-9 is ''Lua runtime vendoring'', unrelated. The JetBrains preset currently maps quick-open to Ctrl+Shift+N (Go to File) and the palette to Ctrl+Shift+A (Find Action) as the expressible IntelliJ equivalents.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 15:07:43', '2026-06-11 10:47:15', NULL, 'b277bfbe5ed8986df00e1e031ad34ad6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB493JEW32CH0H3771TNHF7G', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Give each spawned subagent its own collapsing activity card (don''t merge Task spawns)', 'Filed 2026-06-10 from a user request: when the agent fans out multiple subagents (Task/Agent tool), each spawned subagent should get its OWN collapsing activity card. The space is worth it — a fan-out of N agents should read as N cards, not one lumped card.
CURRENT BEHAVIOUR (confirmed): multiple Task/Agent spawns are MERGED into a single shared ''Activity / N steps'' cluster. groupConversation() in lib/builtin/claude/src/activity_cluster.dart:120-147 walks items and coalesces every consecutive _isFoldable item into one FoldedCluster. _isFoldable (:149-172, AssistantToolUse case at :161-164) only distinguishes diff tools (Edit/Write/MultiEdit/NotebookEdit/Update -> stay first-class) from everything else (Task/Agent/Bash/Read/... -> all foldable). The Task tool is treated identically to a Bash/Read call; there is NO subagent-aware grouping key (not toolUseId, not parent_tool_use_id). So 4 spawned agents render as one ''Activity 4 steps'' card.
What recent work already does (do NOT redo): T-263 folds the subagent PROMPT into the Agent card; T-264 nests the subagent''s RUN items under the parent Agent card (the ''agent run'' collapser); T-338 routes sidechain items to their parent via parent_tool_use_id. All of that is about what shows INSIDE one agent''s card. This ticket is the complement: stop merging DISTINCT agent spawns into a shared cluster.
SCOPE / DESIGN:
- An AssistantToolUse where _isAgentTool(name) (Task/Agent) should break the current Activity cluster and render as its own first-class collapsing card (its own ClideCollapserCard with the prompt + nested ''agent run'' from T-263/T-264), rather than folding into the generic Activity cluster with sibling tool calls.
- Decide grouping precisely in groupConversation/_isFoldable: an Agent tool-use is a cluster boundary (like a sticky item) OR emits its own single-item card. Adjacent non-agent foldables (Bash/Read/Grep) keep clustering into the normal Activity card as today.
- Label each subagent card by its task/description (the Agent call''s label) so parallel fan-outs are distinguishable, not ''Activity N steps''.
- Keep collapsed-by-default behaviour and the FoldLevel semantics; this changes the grouping boundary, not the fold mechanics.
Refs: lib/builtin/claude/src/activity_cluster.dart (groupConversation, _isFoldable, isDiffTool), lib/builtin/claude/src/conversation_view.dart (_ActivityCard at ~833, _toolUseCollapser ~615-657, _isAgentTool ~378). Related: T-230 (clustering), T-263, T-264, T-338.
Tests: a groupConversation case asserting that two consecutive Agent tool-uses yield two separate cards (not one FoldedCluster), while two consecutive Bash calls still yield one Activity cluster.
SCOPE CLARIFICATION (2026-06-10, from user):
1. RIGHT card, not just A card. Each per-agent card must pull in ALL of that agent''s nested run — the folded prompt (T-263) AND every nested response: prose, thinking, sidechain tool cards, and results (T-264) — attributed to the CORRECT agent even under a parallel fan-out where multiple agents'' sidechain items interleave in the stream. Reuse the existing _sidechainFold machinery (conversation_view.dart:188-256): runByToolUseId / promptsByToolUseId are already keyed by the owning Agent''s toolUseId, and resolveOwner''s direct route (conversation_view.dart:210-217) uses parent_tool_use_id (T-338) which disambiguates concurrent agents correctly. THE HAZARD: resolveOwner falls back to ''nearest'' = lastAgent (the most-recently-emitted Agent in stream order; conversation_view.dart:228, applied at :242). In a parallel fan-out any item that lacks parent_tool_use_id and a rooted parentUuid chain would mis-route to whichever agent was emitted last — landing in the WRONG card. Harden this: for the multi-agent case, drop or guard the nearest-lastAgent fallback so an unattributable item is rendered inline/orphaned (resolveOwner already returns null -> handled at :243) rather than mis-filed into a sibling agent''s card.
2. PRESERVE the existing grouping. This ticket only adds an Agent-spawn cluster boundary; it must NOT regress the rest:
- Non-agent foldables (Bash/Read/Grep/LS/etc.) keep coalescing into the generic ''Activity / N steps'' cluster exactly as today (activity_cluster.dart groupConversation/_isFoldable).
- The intra-agent folding stays: prompt-into-call (T-263), run-nested-under-card (T-264), sidechain routing by parent_tool_use_id (T-338). Reuse them; do not rebuild.
- Net behaviour: a fan-out of N agents -> N distinct collapsed cards, each containing its own complete run; surrounding non-agent tool calls still group into their normal Activity card.
Test additions: (a) two concurrent agents whose sidechain items interleave -> each agent''s run items land under its own card, none cross-attributed; (b) an unattributable sidechain item (no parent_tool_use_id, broken chain) is NOT swept into the nearest agent''s card; (c) regression: consecutive Bash/Read calls still form one Activity cluster.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 15:26:33', '2026-06-11 10:49:25', NULL, '95cc390bd8d38f7a3568cf367864d1ae', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'ExitPlanMode does not exit plan mode in the conversation panel', 'Observed live: Claude used the ExitPlanMode tool (approved the plan / left plan mode), but the clide conversation panel did not reflect the exit — it still appears to be in plan mode afterward.
Expected: once ExitPlanMode is accepted, the pane leaves plan mode — the permission-mode indicator (claude_status / permission_mode_control) flips off ''plan'', and the composer / status reflect the new mode.
Where to look:
- How the stream-json session handles the ExitPlanMode tool/control event (stream_json_session.dart) and whether it updates the tracked permission mode (claude_status.dart permissionMode).
- The permission-mode control + status badge wiring (permission_mode_control.dart, claude_pane.dart) — does an ExitPlanMode resolution feed back into the mode the way Ctrl/Cmd+M and the picker do?
- Whether ExitPlanMode arrives as a permission prompt (ToolPromptCard) and, if approved, actually transitions the session out of plan mode rather than just resolving the tool.
Repro: enter plan mode, let Claude present a plan via ExitPlanMode, accept it — the pane stays showing plan mode.
Acceptance: accepting ExitPlanMode leaves plan mode in the conversation panel (mode indicator + behavior update), matching what the underlying session does.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 13:27:36', '2026-06-11 10:49:33', NULL, '96c4174428163a4998490d15c8463149', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB44SKPKTHFMV6WD28GZYPXM', 'task', NULL, 'Support double-tap / bare-modifier chords (JetBrains Search Everywhere)', 'The KeyChord matcher can''t represent a bare or double-tapped modifier: KeyChord.fromKeyEvent returns null for bare modifier presses (key_chord.dart _isBareModifier), and KeyChord.parse requires a base key after the last ''+'', so ''shift+shift'' / ''shift shift'' both fail. Consequence: JetBrains ''Search Everywhere'' (double-Shift) and any double-tap-modifier shortcut can''t be bound.
Decide (design): is search-everywhere its own intent + overlay, or an alias of quick-open? Then extend the chord/sequence matcher to represent a bare-modifier ''key'' (and a double-tap as a sequence) so a preset can bind it.
Surfaced by T-64/T-66 (VS Code / JetBrains keymap presets). Those tickets'' ''see Q-9'' reference is STALE — Q-9 is ''Lua runtime vendoring'', unrelated. The JetBrains preset currently maps quick-open to Ctrl+Shift+N (Go to File) and the palette to Ctrl+Shift+A (Find Action) as the expressible IntelliJ equivalents.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 15:07:43', '2026-06-11 11:27:00', NULL, 'f6605c8b126930cccf4be2d74efa6c4d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'ExitPlanMode does not exit plan mode in the conversation panel', 'Observed live: Claude used the ExitPlanMode tool (approved the plan / left plan mode), but the clide conversation panel did not reflect the exit — it still appears to be in plan mode afterward.
Expected: once ExitPlanMode is accepted, the pane leaves plan mode — the permission-mode indicator (claude_status / permission_mode_control) flips off ''plan'', and the composer / status reflect the new mode.
Where to look:
- How the stream-json session handles the ExitPlanMode tool/control event (stream_json_session.dart) and whether it updates the tracked permission mode (claude_status.dart permissionMode).
- The permission-mode control + status badge wiring (permission_mode_control.dart, claude_pane.dart) — does an ExitPlanMode resolution feed back into the mode the way Ctrl/Cmd+M and the picker do?
- Whether ExitPlanMode arrives as a permission prompt (ToolPromptCard) and, if approved, actually transitions the session out of plan mode rather than just resolving the tool.
Repro: enter plan mode, let Claude present a plan via ExitPlanMode, accept it — the pane stays showing plan mode.
Acceptance: accepting ExitPlanMode leaves plan mode in the conversation panel (mode indicator + behavior update), matching what the underlying session does.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 13:27:36', '2026-06-11 11:27:07', NULL, 'c1a9ecdf23d66e94b96feb08d6f93d60', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'ExitPlanMode does not exit plan mode in the conversation panel', 'Observed live: Claude used the ExitPlanMode tool (approved the plan / left plan mode), but the clide conversation panel did not reflect the exit — it still appears to be in plan mode afterward.
Expected: once ExitPlanMode is accepted, the pane leaves plan mode — the permission-mode indicator (claude_status / permission_mode_control) flips off ''plan'', and the composer / status reflect the new mode.
Where to look:
- How the stream-json session handles the ExitPlanMode tool/control event (stream_json_session.dart) and whether it updates the tracked permission mode (claude_status.dart permissionMode).
- The permission-mode control + status badge wiring (permission_mode_control.dart, claude_pane.dart) — does an ExitPlanMode resolution feed back into the mode the way Ctrl/Cmd+M and the picker do?
- Whether ExitPlanMode arrives as a permission prompt (ToolPromptCard) and, if approved, actually transitions the session out of plan mode rather than just resolving the tool.
Repro: enter plan mode, let Claude present a plan via ExitPlanMode, accept it — the pane stays showing plan mode.
Acceptance: accepting ExitPlanMode leaves plan mode in the conversation panel (mode indicator + behavior update), matching what the underlying session does.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 13:27:36', '2026-06-11 12:35:27', NULL, '8bf60e7ca53d32f5ccfabdbfea4dd84d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB493JEW32CH0H3771TNHF7G', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Give each spawned subagent its own collapsing activity card (don''t merge Task spawns)', 'Filed 2026-06-10 from a user request: when the agent fans out multiple subagents (Task/Agent tool), each spawned subagent should get its OWN collapsing activity card. The space is worth it — a fan-out of N agents should read as N cards, not one lumped card.
CURRENT BEHAVIOUR (confirmed): multiple Task/Agent spawns are MERGED into a single shared ''Activity / N steps'' cluster. groupConversation() in lib/builtin/claude/src/activity_cluster.dart:120-147 walks items and coalesces every consecutive _isFoldable item into one FoldedCluster. _isFoldable (:149-172, AssistantToolUse case at :161-164) only distinguishes diff tools (Edit/Write/MultiEdit/NotebookEdit/Update -> stay first-class) from everything else (Task/Agent/Bash/Read/... -> all foldable). The Task tool is treated identically to a Bash/Read call; there is NO subagent-aware grouping key (not toolUseId, not parent_tool_use_id). So 4 spawned agents render as one ''Activity 4 steps'' card.
What recent work already does (do NOT redo): T-263 folds the subagent PROMPT into the Agent card; T-264 nests the subagent''s RUN items under the parent Agent card (the ''agent run'' collapser); T-338 routes sidechain items to their parent via parent_tool_use_id. All of that is about what shows INSIDE one agent''s card. This ticket is the complement: stop merging DISTINCT agent spawns into a shared cluster.
SCOPE / DESIGN:
- An AssistantToolUse where _isAgentTool(name) (Task/Agent) should break the current Activity cluster and render as its own first-class collapsing card (its own ClideCollapserCard with the prompt + nested ''agent run'' from T-263/T-264), rather than folding into the generic Activity cluster with sibling tool calls.
- Decide grouping precisely in groupConversation/_isFoldable: an Agent tool-use is a cluster boundary (like a sticky item) OR emits its own single-item card. Adjacent non-agent foldables (Bash/Read/Grep) keep clustering into the normal Activity card as today.
- Label each subagent card by its task/description (the Agent call''s label) so parallel fan-outs are distinguishable, not ''Activity N steps''.
- Keep collapsed-by-default behaviour and the FoldLevel semantics; this changes the grouping boundary, not the fold mechanics.
Refs: lib/builtin/claude/src/activity_cluster.dart (groupConversation, _isFoldable, isDiffTool), lib/builtin/claude/src/conversation_view.dart (_ActivityCard at ~833, _toolUseCollapser ~615-657, _isAgentTool ~378). Related: T-230 (clustering), T-263, T-264, T-338.
Tests: a groupConversation case asserting that two consecutive Agent tool-uses yield two separate cards (not one FoldedCluster), while two consecutive Bash calls still yield one Activity cluster.
SCOPE CLARIFICATION (2026-06-10, from user):
1. RIGHT card, not just A card. Each per-agent card must pull in ALL of that agent''s nested run — the folded prompt (T-263) AND every nested response: prose, thinking, sidechain tool cards, and results (T-264) — attributed to the CORRECT agent even under a parallel fan-out where multiple agents'' sidechain items interleave in the stream. Reuse the existing _sidechainFold machinery (conversation_view.dart:188-256): runByToolUseId / promptsByToolUseId are already keyed by the owning Agent''s toolUseId, and resolveOwner''s direct route (conversation_view.dart:210-217) uses parent_tool_use_id (T-338) which disambiguates concurrent agents correctly. THE HAZARD: resolveOwner falls back to ''nearest'' = lastAgent (the most-recently-emitted Agent in stream order; conversation_view.dart:228, applied at :242). In a parallel fan-out any item that lacks parent_tool_use_id and a rooted parentUuid chain would mis-route to whichever agent was emitted last — landing in the WRONG card. Harden this: for the multi-agent case, drop or guard the nearest-lastAgent fallback so an unattributable item is rendered inline/orphaned (resolveOwner already returns null -> handled at :243) rather than mis-filed into a sibling agent''s card.
2. PRESERVE the existing grouping. This ticket only adds an Agent-spawn cluster boundary; it must NOT regress the rest:
- Non-agent foldables (Bash/Read/Grep/LS/etc.) keep coalescing into the generic ''Activity / N steps'' cluster exactly as today (activity_cluster.dart groupConversation/_isFoldable).
- The intra-agent folding stays: prompt-into-call (T-263), run-nested-under-card (T-264), sidechain routing by parent_tool_use_id (T-338). Reuse them; do not rebuild.
- Net behaviour: a fan-out of N agents -> N distinct collapsed cards, each containing its own complete run; surrounding non-agent tool calls still group into their normal Activity card.
Test additions: (a) two concurrent agents whose sidechain items interleave -> each agent''s run items land under its own card, none cross-attributed; (b) an unattributable sidechain item (no parent_tool_use_id, broken chain) is NOT swept into the nearest agent''s card; (c) regression: consecutive Bash/Read calls still form one Activity cluster.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 15:26:33', '2026-06-11 12:35:27', NULL, 'a1faa7af007b8cb1952fb7595e720c4a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB493JEW32CH0H3771TNHF7G', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Give each spawned subagent its own collapsing activity card (don''t merge Task spawns)', 'Filed 2026-06-10 from a user request: when the agent fans out multiple subagents (Task/Agent tool), each spawned subagent should get its OWN collapsing activity card. The space is worth it — a fan-out of N agents should read as N cards, not one lumped card.
CURRENT BEHAVIOUR (confirmed): multiple Task/Agent spawns are MERGED into a single shared ''Activity / N steps'' cluster. groupConversation() in lib/builtin/claude/src/activity_cluster.dart:120-147 walks items and coalesces every consecutive _isFoldable item into one FoldedCluster. _isFoldable (:149-172, AssistantToolUse case at :161-164) only distinguishes diff tools (Edit/Write/MultiEdit/NotebookEdit/Update -> stay first-class) from everything else (Task/Agent/Bash/Read/... -> all foldable). The Task tool is treated identically to a Bash/Read call; there is NO subagent-aware grouping key (not toolUseId, not parent_tool_use_id). So 4 spawned agents render as one ''Activity 4 steps'' card.
What recent work already does (do NOT redo): T-263 folds the subagent PROMPT into the Agent card; T-264 nests the subagent''s RUN items under the parent Agent card (the ''agent run'' collapser); T-338 routes sidechain items to their parent via parent_tool_use_id. All of that is about what shows INSIDE one agent''s card. This ticket is the complement: stop merging DISTINCT agent spawns into a shared cluster.
SCOPE / DESIGN:
- An AssistantToolUse where _isAgentTool(name) (Task/Agent) should break the current Activity cluster and render as its own first-class collapsing card (its own ClideCollapserCard with the prompt + nested ''agent run'' from T-263/T-264), rather than folding into the generic Activity cluster with sibling tool calls.
- Decide grouping precisely in groupConversation/_isFoldable: an Agent tool-use is a cluster boundary (like a sticky item) OR emits its own single-item card. Adjacent non-agent foldables (Bash/Read/Grep) keep clustering into the normal Activity card as today.
- Label each subagent card by its task/description (the Agent call''s label) so parallel fan-outs are distinguishable, not ''Activity N steps''.
- Keep collapsed-by-default behaviour and the FoldLevel semantics; this changes the grouping boundary, not the fold mechanics.
Refs: lib/builtin/claude/src/activity_cluster.dart (groupConversation, _isFoldable, isDiffTool), lib/builtin/claude/src/conversation_view.dart (_ActivityCard at ~833, _toolUseCollapser ~615-657, _isAgentTool ~378). Related: T-230 (clustering), T-263, T-264, T-338.
Tests: a groupConversation case asserting that two consecutive Agent tool-uses yield two separate cards (not one FoldedCluster), while two consecutive Bash calls still yield one Activity cluster.
SCOPE CLARIFICATION (2026-06-10, from user):
1. RIGHT card, not just A card. Each per-agent card must pull in ALL of that agent''s nested run — the folded prompt (T-263) AND every nested response: prose, thinking, sidechain tool cards, and results (T-264) — attributed to the CORRECT agent even under a parallel fan-out where multiple agents'' sidechain items interleave in the stream. Reuse the existing _sidechainFold machinery (conversation_view.dart:188-256): runByToolUseId / promptsByToolUseId are already keyed by the owning Agent''s toolUseId, and resolveOwner''s direct route (conversation_view.dart:210-217) uses parent_tool_use_id (T-338) which disambiguates concurrent agents correctly. THE HAZARD: resolveOwner falls back to ''nearest'' = lastAgent (the most-recently-emitted Agent in stream order; conversation_view.dart:228, applied at :242). In a parallel fan-out any item that lacks parent_tool_use_id and a rooted parentUuid chain would mis-route to whichever agent was emitted last — landing in the WRONG card. Harden this: for the multi-agent case, drop or guard the nearest-lastAgent fallback so an unattributable item is rendered inline/orphaned (resolveOwner already returns null -> handled at :243) rather than mis-filed into a sibling agent''s card.
2. PRESERVE the existing grouping. This ticket only adds an Agent-spawn cluster boundary; it must NOT regress the rest:
- Non-agent foldables (Bash/Read/Grep/LS/etc.) keep coalescing into the generic ''Activity / N steps'' cluster exactly as today (activity_cluster.dart groupConversation/_isFoldable).
- The intra-agent folding stays: prompt-into-call (T-263), run-nested-under-card (T-264), sidechain routing by parent_tool_use_id (T-338). Reuse them; do not rebuild.
- Net behaviour: a fan-out of N agents -> N distinct collapsed cards, each containing its own complete run; surrounding non-agent tool calls still group into their normal Activity card.
Test additions: (a) two concurrent agents whose sidechain items interleave -> each agent''s run items land under its own card, none cross-attributed; (b) an unattributable sidechain item (no parent_tool_use_id, broken chain) is NOT swept into the nearest agent''s card; (c) regression: consecutive Bash/Read calls still form one Activity cluster.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 15:26:33', '2026-06-11 12:57:24', NULL, 'd822618eb175daca9547b05cb07096d4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7CEKQCMZAV751402G', 'story', '06FB0TNQM79H4MPEWW2TWQ89D0', 'clide self-update mechanism', 'Check for new versions on startup (or on demand via command palette). Show a non-intrusive notification when an update is available. Support in-place update without losing running Claude sessions (tmux sessions survive). Respect POLICY.md: no silent network calls on default launch path — the check should be opt-in or gated behind a setting. Consider delta updates for bandwidth efficiency.', 'ready', 'medium', NULL, NULL, NULL, '2026-04-23 20:28:43', '2026-06-11 13:01:55', NULL, '02b5cb1f954c1733edefaee9a8f0a03d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7CEKQCMZAV751402G', 'story', '06FB0TNQM79H4MPEWW2TWQ89D0', 'clide self-update mechanism', 'Check for new versions on startup (or on demand via command palette). Show a non-intrusive notification when an update is available. Support in-place update without losing running Claude sessions (tmux sessions survive). Respect POLICY.md: no silent network calls on default launch path — the check should be opt-in or gated behind a setting. Consider delta updates for bandwidth efficiency.
─────────────────────────────────────────────
REFINED 2026-06-11
## Current state (grounding)
- Version is surfaced at runtime via `lib/src/build_info.g.dart` (`clideVersion`,
`clideCommit`, `clideDate`, `clideRepository` = github.com/postmeridiem/clide),
generated from pubspec by `make gen-build-info`. This is the "installed version".
- Install layout (`make install`): Linux → bundle at `~/.local/lib/clide/`, C client
at `~/.local/bin/clide`, desktop file + icons. macOS → `~/Applications/clide.app`
+ `~/.local/bin/clide`. Windows: not yet shipped.
- clide currently makes NO outbound HTTP calls anywhere in `lib/`. Self-update would
be the FIRST one — so this is a policy-sensitive feature, not just plumbing.
- tmux owns Claude session persistence (D-41); the app re-attaches on restart. An
in-place update that restarts the app does NOT lose sessions — they live in tmux,
outside the bundle.
## HARD CONSTRAINTS (non-negotiable)
- **D-64 (no telemetry / no phone-home):** "No auto-update checks without user
action." This is STRICTER than this ticket''s original "opt-in or gated behind a
setting" wording. A background/startup check — even one a setting enabled — runs
"without user action" at that launch and conflicts with D-64. RESOLUTION: the
version check must be **explicitly user-initiated every time** (a command-palette
"Check for updates…" action / an About-screen button). If we ever want a
startup/periodic check, that needs a deliberate D-64 amendment first — flag, don''t
assume.
- **POLICY.md §"no network on the default launch path":** opening the app, a file,
or typing must never trigger the fetch. The update check + download are explicit
user actions, so they''re allowed — but must meet the §"grudging allowance"
criteria: clear error on failure (not silent), cached result, app fully functional
if the fetch fails.
## BLOCKING PREREQUISITE (likely its own ticket under T-46)
There is no release channel to update FROM today: only 2 git tags (v2.0.0, v2.1.0)
despite being at 2.3.3, no CI (`.github/workflows` is empty), and no published binary
artifacts. Self-update is meaningless without:
1. Consistent, automated release tagging (every `release vX.Y.Z` commit → a tag).
2. CI that builds the per-platform bundles and publishes them as GitHub Releases.
3. Each artifact accompanied by a checksum AND a signature (POLICY.md: "behavior is
determined by the SIGNED release artifact"). An unsigned/unverified download
would break the trust model the update is supposed to preserve.
4. A machine-readable "latest version" source — the GitHub Releases API
(`/repos/postmeridiem/clide/releases/latest`) is the zero-infra option; a
committed `latest.json` manifest is the alternative.
RECOMMENDATION: split this prerequisite into a sibling story "Release channel: CI
build + signed GitHub Releases + version manifest" and make T-47 depend on it.
## DECISIONS TO MAKE (surface before building)
1. Check source: GitHub Releases API vs a hosted `latest.json`. (Lean: Releases API —
no extra infra, origin is already GitHub.)
2. Signature scheme + verification: minisign/age/cosign? Where does the public key
live (vendored in-repo, per POLICY.md provenance)?
3. Delivery: full bundle replacement vs delta/binary-patch (original ask). Lean full
for v1 — deltas are a bandwidth optimization, not correctness; revisit if size hurts.
4. Apply strategy per platform: Linux is easy (swap `~/.local/lib/clide/` + the
`~/.local/bin/clide` client atomically, then relaunch). macOS `.app` replacement +
notarization/quarantine handling is harder. Windows out of scope until it ships.
5. Privilege: user-local installs (`~/.local`, `~/Applications`) need no sudo — good.
A system-wide install would; declare user-local only for v1.
## PROPOSED SCOPE / PHASES (each independently shippable)
P0 (prereq, separate ticket): release channel — tags + CI + signed GitHub Releases.
P1: "Check for updates…" command (palette + About-screen button). Explicit fetch of
the latest release, semver-compare against `clideVersion`, non-intrusive ToastService
notification ("clide X.Y.Z is available") with a "What''s changed" link to the release
notes. No download yet. Clear error toast on network failure. Fully covers the D-64 /
POLICY-compliant "notify" half of the story.
P2: download + signature/checksum verify into a staging dir; show progress; verify before
touching the install.
P3: apply + relaunch (Linux first): atomic swap of bundle + client, restart the app;
tmux sessions survive (D-41). Confirm-before-apply.
P4 (optional): macOS apply path (.app swap + quarantine), delta updates.
## ACCEPTANCE (for the full story; refine per-phase ticket)
- No network call on any default launch path (verified — grep + a test that boot makes
no outbound connection).
- "Check for updates" only runs on explicit user action; failure surfaces a clear
toast, never a silent hang or degraded launch.
- A downloaded update is signature+checksum verified before it can replace the install;
verification failure aborts with the old version intact.
- Applying an update and relaunching preserves running Claude/tmux sessions.
- Version comparison is correct semver (2.3.10 > 2.3.9, pre-release handling defined).
## REFERENCES
POLICY.md (network rule + grudging-allowance criteria); D-64 (no phone-home);
D-41 (tmux session persistence); `lib/src/build_info.g.dart` (version source);
`lib/kernel/src/toast.dart` (ToastService — the notification); settings bool pattern
(`app.*.enabled`, `lib/kernel/src/extensions_manager.dart`); `Makefile` install target
(per-platform layout); parent epic T-46 (cross-platform installer).', 'ready', 'medium', NULL, NULL, NULL, '2026-04-23 20:28:43', '2026-06-11 13:30:03', NULL, '73a6def84544128c6212852d22c58e9f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7CEKQCMZAV751402G', 'story', '06FB0TNQM79H4MPEWW2TWQ89D0', 'clide self-update mechanism', 'Check for new versions on startup (or on demand via command palette). Show a non-intrusive notification when an update is available. Support in-place update without losing running Claude sessions (tmux sessions survive). Respect POLICY.md: no silent network calls on default launch path — the check should be opt-in or gated behind a setting. Consider delta updates for bandwidth efficiency.
─────────────────────────────────────────────
REFINED 2026-06-11
## Current state (grounding)
- Version is surfaced at runtime via `lib/src/build_info.g.dart` (`clideVersion`,
`clideCommit`, `clideDate`, `clideRepository` = github.com/postmeridiem/clide),
generated from pubspec by `make gen-build-info`. This is the "installed version".
- Install layout (`make install`): Linux → bundle at `~/.local/lib/clide/`, C client
at `~/.local/bin/clide`, desktop file + icons. macOS → `~/Applications/clide.app`
+ `~/.local/bin/clide`. Windows: not yet shipped.
- clide currently makes NO outbound HTTP calls anywhere in `lib/`. Self-update would
be the FIRST one — so this is a policy-sensitive feature, not just plumbing.
- tmux owns Claude session persistence (D-41); the app re-attaches on restart. An
in-place update that restarts the app does NOT lose sessions — they live in tmux,
outside the bundle.
## HARD CONSTRAINTS (non-negotiable)
- **D-64 (no telemetry / no phone-home):** "No auto-update checks without user
action." This is STRICTER than this ticket''s original "opt-in or gated behind a
setting" wording. A background/startup check — even one a setting enabled — runs
"without user action" at that launch and conflicts with D-64. RESOLUTION: the
version check must be **explicitly user-initiated every time** (a command-palette
"Check for updates…" action / an About-screen button). If we ever want a
startup/periodic check, that needs a deliberate D-64 amendment first — flag, don''t
assume.
- **POLICY.md §"no network on the default launch path":** opening the app, a file,
or typing must never trigger the fetch. The update check + download are explicit
user actions, so they''re allowed — but must meet the §"grudging allowance"
criteria: clear error on failure (not silent), cached result, app fully functional
if the fetch fails.
## BLOCKING PREREQUISITE (likely its own ticket under T-46)
There is no release channel to update FROM today: only 2 git tags (v2.0.0, v2.1.0)
despite being at 2.3.3, no CI (`.github/workflows` is empty), and no published binary
artifacts. Self-update is meaningless without:
1. Consistent, automated release tagging (every `release vX.Y.Z` commit → a tag).
2. CI that builds the per-platform bundles and publishes them as GitHub Releases.
3. Each artifact accompanied by a checksum AND a signature (POLICY.md: "behavior is
determined by the SIGNED release artifact"). An unsigned/unverified download
would break the trust model the update is supposed to preserve.
4. A machine-readable "latest version" source — the GitHub Releases API
(`/repos/postmeridiem/clide/releases/latest`) is the zero-infra option; a
committed `latest.json` manifest is the alternative.
RECOMMENDATION: split this prerequisite into a sibling story "Release channel: CI
build + signed GitHub Releases + version manifest" and make T-47 depend on it.
## DECISIONS TO MAKE (surface before building)
1. Check source: GitHub Releases API vs a hosted `latest.json`. (Lean: Releases API —
no extra infra, origin is already GitHub.)
2. Signature scheme + verification: minisign/age/cosign? Where does the public key
live (vendored in-repo, per POLICY.md provenance)?
3. Delivery: full bundle replacement vs delta/binary-patch (original ask). Lean full
for v1 — deltas are a bandwidth optimization, not correctness; revisit if size hurts.
4. Apply strategy per platform: Linux is easy (swap `~/.local/lib/clide/` + the
`~/.local/bin/clide` client atomically, then relaunch). macOS `.app` replacement +
notarization/quarantine handling is harder. Windows out of scope until it ships.
5. Privilege: user-local installs (`~/.local`, `~/Applications`) need no sudo — good.
A system-wide install would; declare user-local only for v1.
## PROPOSED SCOPE / PHASES (each independently shippable)
P0 (prereq, separate ticket): release channel — tags + CI + signed GitHub Releases.
P1: "Check for updates…" command (palette + About-screen button). Explicit fetch of
the latest release, semver-compare against `clideVersion`, non-intrusive ToastService
notification ("clide X.Y.Z is available") with a "What''s changed" link to the release
notes. No download yet. Clear error toast on network failure. Fully covers the D-64 /
POLICY-compliant "notify" half of the story.
P2: download + signature/checksum verify into a staging dir; show progress; verify before
touching the install.
P3: apply + relaunch (Linux first): atomic swap of bundle + client, restart the app;
tmux sessions survive (D-41). Confirm-before-apply.
P4 (optional): macOS apply path (.app swap + quarantine), delta updates.
## ACCEPTANCE (for the full story; refine per-phase ticket)
- No network call on any default launch path (verified — grep + a test that boot makes
no outbound connection).
- "Check for updates" only runs on explicit user action; failure surfaces a clear
toast, never a silent hang or degraded launch.
- A downloaded update is signature+checksum verified before it can replace the install;
verification failure aborts with the old version intact.
- Applying an update and relaunching preserves running Claude/tmux sessions.
- Version comparison is correct semver (2.3.10 > 2.3.9, pre-release handling defined).
## REFERENCES
POLICY.md (network rule + grudging-allowance criteria); D-64 (no phone-home);
D-41 (tmux session persistence); `lib/src/build_info.g.dart` (version source);
`lib/kernel/src/toast.dart` (ToastService — the notification); settings bool pattern
(`app.*.enabled`, `lib/kernel/src/extensions_manager.dart`); `Makefile` install target
(per-platform layout); parent epic T-46 (cross-platform installer).', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-23 20:28:43', '2026-06-11 13:30:47', NULL, '28d5684adc9127f1306bddaaff8d1f77', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBDSJYQFDNKP4KA1JAEDSS8W', 'initiative', NULL, 'Internalize pql: bundle the binary and own workspace onboarding', NULL, 'backlog', 'medium', NULL, NULL, 'D-92', '2026-06-11 13:36:51', '2026-06-11 13:36:51', NULL, '28d6eb7a1b8658ae5db18328446cbf69', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBDSKGAHYHH2NPZK8B6EV4D4', 'epic', '06FBDSJYQFDNKP4KA1JAEDSS8W', 'Bundle and resolve pql as a vendored pinned binary', NULL, 'backlog', 'medium', NULL, NULL, 'D-92', '2026-06-11 13:36:55', '2026-06-11 13:36:55', NULL, 'cd19e7f43063ceaa61e8cb0dc68a6268', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBDSM0PRGYR61R0NWYAT9VDC', 'epic', '06FBDSJYQFDNKP4KA1JAEDSS8W', 'Single-directory workspace footprint: relocate IDE state to user scope', NULL, 'backlog', 'medium', NULL, NULL, 'D-93', '2026-06-11 13:37:00', '2026-06-11 13:37:00', NULL, 'e81d105b64347dfb59a81a28cbc1c594', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBDSPECQ0FPKB9SYTD7KZSBM', 'epic', '06FBDSJYQFDNKP4KA1JAEDSS8W', 'Workspace onboarding flow and read-mode degrade', NULL, 'backlog', 'medium', NULL, NULL, 'D-95', '2026-06-11 13:37:19', '2026-06-11 13:37:19', NULL, '91b0ac3a4bb69c24f7c9eef9034f7ca3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBDSQ2GBSP0ZH4RHZG2PMR0R', 'task', '06FBDSPECQ0FPKB9SYTD7KZSBM', 'Declared extension modes (manifest) + classify builtins as read-mode-safe', NULL, 'backlog', 'medium', NULL, NULL, 'D-94', '2026-06-11 13:37:25', '2026-06-11 13:37:25', NULL, '1aa454e0f1de6710b57c7fd56fd5194a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBDSJYQFDNKP4KA1JAEDSS8W', 'initiative', NULL, 'Internalize pql: bundle the binary and own workspace onboarding', 'Eliminate the out-of-repo pql dependency friction: clide ships its own pinned pql and owns first-open workspace setup. Spans four decisions — D-92 (bundle pql), D-93 (zero clide dirs in-repo), D-94 (workspace modes), D-95 (onboarding + read-mode). Outcome: a fresh clone/install of clide works with no separate pql install or version coordination; the repo''s only tool dirs are .git/ and .pql/. Three epics: T-355 bundle, T-356 footprint, T-357 onboarding.', 'backlog', 'medium', NULL, NULL, 'D-92', '2026-06-11 13:36:51', '2026-06-11 13:37:57', NULL, 'fef23cd1e616df992b40126e76cd4377', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBDSKGAHYHH2NPZK8B6EV4D4', 'epic', '06FBDSJYQFDNKP4KA1JAEDSS8W', 'Bundle and resolve pql as a vendored pinned binary', 'Vendor a version-pinned pql binary per shipped platform under native/<platform>/, following the dugite pattern (D-59/D-63): BUILD.md provenance (upstream commit SHA, build command, toolchain, sha256), assets/licenses.yaml entry (D-42/D-65). Add a bundled-first resolver mirroring _resolveDugiteGit() in lib/kernel/src/toolchain_paths.dart: CLIDE_PQL_BIN env override -> binary next to the executable -> system pql on PATH (currently pql is PATH-only via _findOnPath at line 88). SECURITY: resolve against the install dir only, never workspace-relative — a planted ./native/pql is a code-exec vector (the T-98 dugite lesson). Bundled copy must not self-update. Add a soft version-floor check that surfaces an out-of-date override/PATH pql in the Problems panel. Switch CI to run the in-tree binary instead of assuming pql on the runner.', 'backlog', 'medium', NULL, NULL, 'D-92', '2026-06-11 13:36:55', '2026-06-11 13:38:05', NULL, '0df8e53ac69113dab9baa174c0f90ff6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBDSM0PRGYR61R0NWYAT9VDC', 'epic', '06FBDSJYQFDNKP4KA1JAEDSS8W', 'Single-directory workspace footprint: relocate IDE state to user scope', 'Move clide''s project-scoped state out of the in-repo .clide/ dir into user scope, keyed by a workspace-path hash (reuse the FNV-1a convention from D-70''s IPC socket path). Affected today: SettingsStore project file (_projectFile -> .clide/settings.yaml in lib/kernel/src/settings.dart) and theme_persistence.dart (project.theme). Provide a one-time migration that relocates an existing .clide/settings.yaml to user scope and removes the dir. Drop .clide/ from the gitignore-at-install set (only .pql/ remains). If shared/committed clide config is ever needed, it goes as clide-owned keys in .pql/config.yaml, not a new dir. Note: the open extension-DB question (governance/questions/process.md Q on .clide/clide.db) now assumes a user-scope DB. Accept D-70''s trade-off: moving/renaming a repo re-keys it and resets personal layout.', 'backlog', 'medium', NULL, NULL, 'D-93', '2026-06-11 13:37:00', '2026-06-11 13:38:13', NULL, 'baf3e0de9a0b61b32d37638267445e1f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBDSPECQ0FPKB9SYTD7KZSBM', 'epic', '06FBDSJYQFDNKP4KA1JAEDSS8W', 'Workspace onboarding flow and read-mode degrade', 'Add a first-open, idempotent workspace prep flow that reconciles state (virgin / pql-user / partially-init / fully-init / previously-declined) rather than blindly running pql init. Two ordered gates: (1) non-git folder -> OFFER git init, default NO, with a guard that warns when a parent .git would create a nested repo; or pick another folder. (2) pql provisioning, now that pql ships bundled (T-355): config+index is the mandatory floor (the files/query/ignore engine); the planning layer (decisions/tickets + changelog hooks, D-67) is a CONTEXTUAL opt-in offered at first open of the Decisions/Tickets surface, with disclosure. The modal must disclose everything it writes (.gitignore entries, pql config, and — opt-in only — git hooks). Handle the friction.md gotcha: pql init writes to .git/hooks and ignores an existing core.hooksPath; do not clobber it. Writable repo w/o .pql = invalid-until-init. Unwritable repo (read-only mount / no perms) -> degrade to read mode: file tree + editor + D-79 grep stay live, pql surfaces dark behind a banner (depends on T-358 modes). Remember a decline in user scope keyed by repo path; provide an explicit ''initialize workspace'' command; no re-nagging.', 'backlog', 'medium', NULL, NULL, 'D-95', '2026-06-11 13:37:19', '2026-06-11 13:38:23', NULL, '7508bf6aa6dac26cfef8069f0b305c1b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBDSQ2GBSP0ZH4RHZG2PMR0R', 'task', '06FBDSPECQ0FPKB9SYTD7KZSBM', 'Declared extension modes (manifest) + classify builtins as read-mode-safe', 'Introduce a ''modes'' capability in the extension manifest (lib/extension/src/manifest.dart) as an open vocabulary: edit and read now, with remote/ssh/webui reserved (D-94). The extension host (lib/extension/src/host.dart) activates an extension only when the active workspace mode is in its declared set; an undeclared extension defaults to edit-only. Then classify the builtins: read-mode-safe = editor (view), files (tree + D-79 grep), git (status/log/diff viewing), terminal, claude; goes dark = pql (search/query/backlinks), decisions, tickets, graph; partial = problems (keep non-pql diagnostics, drop the pql.doctor row). This is the substrate read-mode degrade (T-357) gates on, and the seam the SSH-remote question (Q-23) is expected to resolve into.', 'backlog', 'medium', NULL, NULL, 'D-94', '2026-06-11 13:37:25', '2026-06-11 13:38:31', NULL, 'ba097febcafc3595656faa9a25b75a41', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2T11GCV1EV07DYD5BZENTM', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Live tail sub-card inside expanded Bash activity cards', 'Expanded Bash activity cards should be able to show a live, scrolling tail of a file-backed source the command is following, so the user can watch the same output Claude is tailing — connected lazily, only while the card is expanded.
**Motivation**
Long-running / tailing Bash steps render only a final result block once the command finishes (see screenshot in T-304 thread). The user can''t follow progress. We want an embedded scrolling terminal sub-card inside the expanded activity card for visibility.
**Hard constraint (feasibility, established by investigation)**
Claude Code CLI executes every Bash tool itself; clide only ingests stream-json events (stream_json_session.dart -> parseTranscriptChunk). A tool_result arrives as ONE complete block — clide never sees in-flight tool output, and the controller''s in-place upsert path is hardcoded for `partial-<msg_id>` prose deltas, not tool results (controller.dart). We cannot tap Claude''s subprocess fd. So a fully-general "mirror the running command''s stdout" is NOT possible.
**Viable scope: independent file-tail on expand**
- On card expand, parse the Bash command string (AssistantToolUse.input["command"], transcript_reader.dart) for a file-backed source: `tail -f X`, `tail -n N X`, `cat X`, `less X`, known log paths, or a background task writing to a file.
- If found, clide opens its OWN read-only follower — a NativePty running `tail -f X` (native_pty.dart already exposes Stream<Uint8List>) or a Dart file-watcher — and renders it in an embedded scrolling terminal sub-card, reusing TerminalView.
- Lazy lifecycle: connect on expand, disconnect/dispose on collapse. No connection until expanded.
- Read-only & safe: clide observes the same file; it never re-runs or intercepts Claude''s command.
- Graceful when there is no file-backed source (e.g. `git push ... | tail -25`, a pipe inside Claude''s process): show a muted "no independent source to follow" note rather than faking output.
**Where**
- Card body: conversation_card.dart / conversation_view.dart (_toolUseCollapser, the Bash tool card). Add an optional live-tail segment below the RESULT segment.
- Key the live connection on AssistantToolUse.toolUseId.
- Embedded terminal: reuse lib/src/terminal TerminalView + lib/src/pty NativePty.
**Open questions (settle in review / may need a Q-record)**
- Command-parsing surface: which commands/forms count as "tailable"? Keep it a small, explicit allowlist to avoid mis-following.
- Security: only follow paths inside the workspace? How to handle absolute paths outside the repo.
- Lifecycle when the underlying command has already finished (file static) vs still running.
**Acceptance**
- Expanding a Bash card whose command tails a file shows a live scrolling terminal sub-card that updates as the file grows; collapsing it tears the follower down.
- A Bash card with no file-backed source shows a clear "nothing to follow" affordance, never a broken/empty terminal.
- No follower process is spawned until the card is expanded.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 12:00:52', '2026-06-11 14:18:34', NULL, 'c987cb650ad845e72e459441e0d72aa9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2T11GCV1EV07DYD5BZENTM', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Live tail sub-card inside expanded Bash activity cards', 'Expanded Bash activity cards should be able to show a live, scrolling tail of a file-backed source the command is following, so the user can watch the same output Claude is tailing — connected lazily, only while the card is expanded.
**Motivation**
Long-running / tailing Bash steps render only a final result block once the command finishes (see screenshot in T-304 thread). The user can''t follow progress. We want an embedded scrolling terminal sub-card inside the expanded activity card for visibility.
**Hard constraint (feasibility, established by investigation)**
Claude Code CLI executes every Bash tool itself; clide only ingests stream-json events (stream_json_session.dart -> parseTranscriptChunk). A tool_result arrives as ONE complete block — clide never sees in-flight tool output, and the controller''s in-place upsert path is hardcoded for `partial-<msg_id>` prose deltas, not tool results (controller.dart). We cannot tap Claude''s subprocess fd. So a fully-general "mirror the running command''s stdout" is NOT possible.
**Viable scope: independent file-tail on expand**
- On card expand, parse the Bash command string (AssistantToolUse.input["command"], transcript_reader.dart) for a file-backed source: `tail -f X`, `tail -n N X`, `cat X`, `less X`, known log paths, or a background task writing to a file.
- If found, clide opens its OWN read-only follower — a NativePty running `tail -f X` (native_pty.dart already exposes Stream<Uint8List>) or a Dart file-watcher — and renders it in an embedded scrolling terminal sub-card, reusing TerminalView.
- Lazy lifecycle: connect on expand, disconnect/dispose on collapse. No connection until expanded.
- Read-only & safe: clide observes the same file; it never re-runs or intercepts Claude''s command.
- Graceful when there is no file-backed source (e.g. `git push ... | tail -25`, a pipe inside Claude''s process): show a muted "no independent source to follow" note rather than faking output.
**Where**
- Card body: conversation_card.dart / conversation_view.dart (_toolUseCollapser, the Bash tool card). Add an optional live-tail segment below the RESULT segment.
- Key the live connection on AssistantToolUse.toolUseId.
- Embedded terminal: reuse lib/src/terminal TerminalView + lib/src/pty NativePty.
**Open questions (settle in review / may need a Q-record)**
- Command-parsing surface: which commands/forms count as "tailable"? Keep it a small, explicit allowlist to avoid mis-following.
- Security: only follow paths inside the workspace? How to handle absolute paths outside the repo.
- Lifecycle when the underlying command has already finished (file static) vs still running.
**Acceptance**
- Expanding a Bash card whose command tails a file shows a live scrolling terminal sub-card that updates as the file grows; collapsing it tears the follower down.
- A Bash card with no file-backed source shows a clear "nothing to follow" affordance, never a broken/empty terminal.
- No follower process is spawned until the card is expanded.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 12:00:52', '2026-06-11 14:59:53', NULL, '2b485906c225f811862fb5ec97ae37cf', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2T11GCV1EV07DYD5BZENTM', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Live tail sub-card inside expanded Bash activity cards', 'Expanded Bash activity cards should be able to show a live, scrolling tail of a file-backed source the command is following, so the user can watch the same output Claude is tailing — connected lazily, only while the card is expanded.
**Motivation**
Long-running / tailing Bash steps render only a final result block once the command finishes (see screenshot in T-304 thread). The user can''t follow progress. We want an embedded scrolling terminal sub-card inside the expanded activity card for visibility.
**Hard constraint (feasibility, established by investigation)**
Claude Code CLI executes every Bash tool itself; clide only ingests stream-json events (stream_json_session.dart -> parseTranscriptChunk). A tool_result arrives as ONE complete block — clide never sees in-flight tool output, and the controller''s in-place upsert path is hardcoded for `partial-<msg_id>` prose deltas, not tool results (controller.dart). We cannot tap Claude''s subprocess fd. So a fully-general "mirror the running command''s stdout" is NOT possible.
**Viable scope: independent file-tail on expand**
- On card expand, parse the Bash command string (AssistantToolUse.input["command"], transcript_reader.dart) for a file-backed source: `tail -f X`, `tail -n N X`, `cat X`, `less X`, known log paths, or a background task writing to a file.
- If found, clide opens its OWN read-only follower — a NativePty running `tail -f X` (native_pty.dart already exposes Stream<Uint8List>) or a Dart file-watcher — and renders it in an embedded scrolling terminal sub-card, reusing TerminalView.
- Lazy lifecycle: connect on expand, disconnect/dispose on collapse. No connection until expanded.
- Read-only & safe: clide observes the same file; it never re-runs or intercepts Claude''s command.
- Graceful when there is no file-backed source (e.g. `git push ... | tail -25`, a pipe inside Claude''s process): show a muted "no independent source to follow" note rather than faking output.
**Where**
- Card body: conversation_card.dart / conversation_view.dart (_toolUseCollapser, the Bash tool card). Add an optional live-tail segment below the RESULT segment.
- Key the live connection on AssistantToolUse.toolUseId.
- Embedded terminal: reuse lib/src/terminal TerminalView + lib/src/pty NativePty.
**Open questions (settle in review / may need a Q-record)**
- Command-parsing surface: which commands/forms count as "tailable"? Keep it a small, explicit allowlist to avoid mis-following.
- Security: only follow paths inside the workspace? How to handle absolute paths outside the repo.
- Lifecycle when the underlying command has already finished (file static) vs still running.
**Acceptance**
- Expanding a Bash card whose command tails a file shows a live scrolling terminal sub-card that updates as the file grows; collapsing it tears the follower down.
- A Bash card with no file-backed source shows a clear "nothing to follow" affordance, never a broken/empty terminal.
- No follower process is spawned until the card is expanded.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 12:00:52', '2026-06-11 17:09:12', NULL, '050a31bfe1ef9cf220f306ab3922f84c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBGHNEQTAEPGNJKN42C1E8', 'epic', NULL, 'Address 2026-06-11 Fable review (fable-ous.md)', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:54:36', '2026-06-11 21:54:36', NULL, 'a4819e309facb7a990372e7e2eec2561', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBGHNEQTAEPGNJKN42C1E8', 'epic', NULL, 'Address 2026-06-11 Fable review (fable-ous.md)', 'Thirteen parallel subsystem reviewers + adversarial verification over ~58k LOC produced ~14 high-severity and ~35 medium findings plus systemic patterns and a feature backlog. Source: fable-ous.md (committed alongside this epic). Children are filed individually so they can land independently; feature proposals went to governance/questions as Q-records, not tickets.
Acceptance: all dragon (high-severity) findings resolved or formally rejected with a D-record; scorpions shipped or moved to follow-up work with rationale; rat-extermination and systemic-pattern batches closed; god-file split plans written into their tickets.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:54:36', '2026-06-11 21:54:44', NULL, '77ba1d6c54e7c950dd8eac675ce91d4f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBJ5T7HAQ9CA8XQMX43A2C', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'PTY master fd leaks on every natural child exit', NULL, 'backlog', 'critical', NULL, NULL, NULL, '2026-06-11 21:54:49', '2026-06-11 21:54:49', NULL, '925f4826638fbf190a370b08892d47cd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBJ5T7HAQ9CA8XQMX43A2C', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'PTY master fd leaks on every natural child exit', 'lib/src/pty/native_pty.dart:444-450 — on child EOF, _reap() sets _dead = true but never closes _fd; a later close() short-circuits at `if (_dead) return;` (line ~460) so _nativeClose(_fd) (line ~477) never runs. Every terminal/Claude pane whose child exits on its own leaks an fd and a pty device for the life of the app. Two independent reviewers confirmed.
Fix: close the master fd in the natural-exit path (or let close() proceed to fd teardown when dead). Add a test asserting the fd is released after child EOF.', 'backlog', 'critical', NULL, NULL, NULL, '2026-06-11 21:54:49', '2026-06-11 21:54:56', NULL, '0ce16b345f5e129abea63d6314b61bad', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBKK2TZQK683J8FS0ZH5A4', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Claude child process observed only via stdout: stderr never drained, exit never watched', NULL, 'backlog', 'critical', NULL, NULL, NULL, '2026-06-11 21:55:01', '2026-06-11 21:55:01', NULL, '70beb5d53ac502257e86c82b520886f5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBKK2TZQK683J8FS0ZH5A4', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Claude child process observed only via stdout: stderr never drained, exit never watched', 'lib/builtin/claude/src/stream_json_session.dart:43-78 — the claude child stderr is never drained: >=64KB of --verbose spew fills the pipe, the child blocks mid-turn, and the flagship pane wedges with zero diagnostics. Nothing watches exitCode or stdout onDone (line ~303), so a crashed/dead session just looks busy.
Fix: drain stderr into a bounded ring buffer (surface it on failure), watch exitCode/onDone, and emit a terminal SessionEnded state to the pane. Intersects T-283 (resume hang has no timeout/fallback). Tests: dead-child surfaces SessionEnded; stderr flood does not wedge the session.', 'backlog', 'critical', NULL, NULL, NULL, '2026-06-11 21:55:01', '2026-06-11 21:55:07', NULL, 'b9048d2475722e1d364044346f61cc2f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBN5F0F8SDF15P21DNKT1W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'MCP HTTP server exposes the full dispatcher with zero auth', NULL, 'backlog', 'critical', NULL, NULL, NULL, '2026-06-11 21:55:13', '2026-06-11 21:55:13', NULL, 'f4f3b80272a12a8ec2aa561a353c0249', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBN5F0F8SDF15P21DNKT1W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'MCP HTTP server exposes the full dispatcher with zero auth', 'lib/src/ipc/mcp_server.dart:138-195, started unconditionally at boot (lib/main.dart:174-180). D-71 threat model (another user on the same host must not drive my IDE) is enforced with 0600 on the unix socket — then bypassed wholesale by an unauthenticated localhost HTTP port that, since D-86, serves every clide verb as a tool.
Fix: generate a token in the lock file (Claude Code /ide lock format has a slot for it) and require the auth header on every request. Tests: request without token is rejected; token round-trips via the lock file.', 'backlog', 'critical', NULL, NULL, NULL, '2026-06-11 21:55:13', '2026-06-11 21:55:21', NULL, '50afa532cebd52e717eb790ad77f705e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBPQE4J4YBJX92812ZK6DR', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'editor.open/editor.save skip path confinement entirely', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:55:26', '2026-06-11 21:55:26', NULL, 'dd2945aa9bd4bb2b4a908790243170c7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBPQE4J4YBJX92812ZK6DR', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'editor.open/editor.save skip path confinement entirely', 'lib/src/editor/registry.dart:215-219 returns absolute paths verbatim — no .. normalization, no path_safety call — an unconfined read AND write primitive over IPC while files.read is carefully guarded.
Fix: route editor.open/editor.save through path_safety like files.*. Tests: traversal and absolute-escape attempts rejected for both verbs. Longer-term the confinement should move to the dispatch layer (see the systemic ticket filed with this epic).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:55:26', '2026-06-11 21:55:33', NULL, 'e6ce37ceaff954053227d353033886f1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBR4636GSRJBWFJDAZ6ZA0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'search.replace silently ignores its include/exclude globs', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:55:38', '2026-06-11 21:55:38', NULL, '674a5d3b1a455b9f03a0a2383f7ca4e3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBR4636GSRJBWFJDAZ6ZA0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'search.replace silently ignores its include/exclude globs', 'lib/src/search/replace_engine.dart:124-143 — the include/exclude glob filters the user typed are accepted but never applied; replace will happily rewrite files outside the filter.
Fix: apply the same glob filtering the search side uses before rewriting. Test: replace with an include glob touches only matching files; exclude glob is honored.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:55:38', '2026-06-11 21:55:44', NULL, '749e1d21bafd775069eb747233632779', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBSG6356MZJ2DCCCSBMBGM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'listDir symlink detection is dead code; walkFiles descends symlinked dirs', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:55:49', '2026-06-11 21:55:49', NULL, '468bd54e7a036cd26fa707df54c4ea97', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBSG6356MZJ2DCCCSBMBGM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'listDir symlink detection is dead code; walkFiles descends symlinked dirs', 'lib/src/files/listing.dart:46-54 — stat() follows links, so isSymlink is always false; walkFiles therefore descends symlinked directories the docs claim it skips (escape hatch out of the workspace, plus cycle risk).
Fix: use lstat (FileStat via Link check / FileSystemEntity.isLinkSync on the raw path) for symlink detection. Tests: symlinked dir is reported as symlink and not descended; symlink cycle does not hang the walk.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:55:49', '2026-06-11 21:55:56', NULL, '2fc8186724d131d03b43a68e46dd5f70', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBV0465906BY3QFAY9F1YM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Kernel lookup in dispose() leaks shell PTYs and settings listeners', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:56:01', '2026-06-11 21:56:01', NULL, 'e0ff148be03a173e407a256ba72d19b6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBV0465906BY3QFAY9F1YM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Kernel lookup in dispose() leaks shell PTYs and settings listeners', 'lib/builtin/terminal/src/terminal_pane.dart:131-137 calls ClideKernel.of(context) from dispose() — illegal ancestor lookup, swallowed by catch (_) — so pane.close is never sent and the backend PTY + daemon pane leak on every closed terminal pane. The same idiom leaks the settings listener in every disposed ClaudePane (lib/builtin/claude/src/claude_pane.dart:460-466).
Fix: cache the kernel ref in didChangeDependencies, delete the catch-alls. Combined with the PTY natural-exit fd leak this is a two-stage leak pipeline. Tests: closing a terminal pane sends pane.close; disposing a ClaudePane removes its settings listener.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:56:01', '2026-06-11 21:56:09', NULL, '4f703d28761e63c84de96abdee86cd9e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBWE2W1226T58CX37E50HC', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Project switch leaks the entire previous workspace service set', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:56:13', '2026-06-11 21:56:13', NULL, '33ae9e7ac573f6d207297ca6f7758c98', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBWE2W1226T58CX37E50HC', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Project switch leaks the entire previous workspace service set', 'lib/main.dart:335-344 — switching projects builds a new dispatcher with fresh PaneRegistry, FilesService, EditorRegistry, etc., but nothing calls the old set''s shutdown() methods (which exist and have zero callers). Old file watchers keep emitting into the new workspace''s bus.
Fix: dispose/shutdown the previous service set before (or while) standing up the new one. Test: after a workspace switch, the old FilesService watcher no longer delivers events.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:56:13', '2026-06-11 21:56:19', NULL, 'dc274dbd0cd8573daae37acfcf54b6f9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5W6VN98RQM6S22X28', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Claude bottom status bar often stays empty / doesn''t update', 'The bottom status-bar context slot for the Claude pane (model · permission-mode · context, T-145/T-150) frequently renders empty and doesn''t update. The slot is fed by the active pane''s status widget via the focus service (PaneContextStatusItem -> ClidePane.statusWidget in claude_pane.dart), sourced from StreamJsonSession.statusStream (model/permissionMode from the ''system/init'' event; cost/contextWindow from ''result'' events in stream_json_session.dart).
Repro: open clide; the status line is often blank and stays blank until/unless a turn completes (or never populates).
Likely suspects to investigate:
- status only published while the pane is the focused contribution (active==true) — if focus isn''t on the Claude pane, the slot clears.
- statusStream may not emit until the first ''result''/''init'' event; a resumed session (--resume) may not re-emit init, so model/mode never arrive.
- _statusWidget returns null when _status.isEmpty AND no skills, so an unstarted/!init session shows nothing.
- focus-slot wiring (FocusTracker) may not re-publish on pane (re)build / session rebind.
Acceptance: the Claude status line shows model · mode · context promptly after a session starts/resumes and stays current across turns and focus changes; add a test covering the resumed-session (no fresh init) case.
Root cause found and verified by the 2026-06-11 Fable review (fable-ous.md, epic T-359): statusStream is a plain broadcast controller — the system/init event fires while spawn() is still awaiting a 256KB transcript-tail read, before the pane ever subscribes (lib/builtin/claude/src/claude_pane.dart:322, lib/builtin/claude/src/session_orchestrator.dart:222-225). Fix: seed from session.status on bind, or make the stream replay-latest (see the ValueStream systemic ticket under T-359).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-08 07:26:13', '2026-06-11 21:56:28', NULL, '1e65d7073c7407585c2e6a79c330881e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Conversation auto-scroll yanks a scrolled-up reader to the bottom on every streamed token', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:56:33', '2026-06-11 21:56:33', NULL, '4792af8e6089499f70a87245d351573d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Conversation auto-scroll yanks a scrolled-up reader to the bottom on every streamed token', 'lib/builtin/claude/src/conversation_view.dart:268-277 — the _atBottom pin exists but is only consulted on viewport resize, not on new items. Anyone reading earlier output during a long streaming reply is dragged to the bottom continuously.
Fix: gate the new-item auto-scroll on _atBottom (one-line) and add the missing twin test: scrolled-up viewport stays put when items stream in; at-bottom viewport follows.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:56:33', '2026-06-11 21:56:40', NULL, '9efd0c7170841e18d9e21e29e3d75705', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal throws RangeError on truncated SGR 38/48; colon sub-parameters mangled', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:56:47', '2026-06-11 21:56:47', NULL, '9ab6c8b23458dd442a74791765541d0e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal throws RangeError on truncated SGR 38/48; colon sub-parameters mangled', 'lib/src/terminal/src/core/escape/parser.dart:501-516 — SGR 38/48 extended-color parsing does unguarded params[i + 1] lookahead, so a truncated sequence like printf ''\e[38m'' throws RangeError inside Terminal.write. An emulator must never throw on hostile bytes. Secondary, same code path: colon-form SGR sub-parameters (e.g. 38:2:r:g:b, emitted by modern terminfo) are not split out and get mangled into bogus params.
Fix: bounds-check the lookahead (ignore incomplete 38/48 sequences), and parse colon-form sub-parameters per ECMA-48/ITU T.416 — treat 38:2::r:g:b and 38;2;r;g;b equivalently. Note T-123 (parser split) touches the same file; coordinate but do not block on it.
Acceptance: feeding any truncated/garbled SGR byte sequence never throws (fuzz-style test over partial sequences); colon-form truecolor sets the same fg/bg as semicolon form; existing SGR tests stay green.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:56:47', '2026-06-11 21:56:59', NULL, 'ee56f737a2da9d931af86807ea41db91', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC2NM7AYKENZ0ZD49HAX1W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'ClideCollapserCard excludeSemantics wipes expanded children from the a11y tree', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:57:04', '2026-06-11 21:57:04', NULL, 'bbae48853b49cec865195440f76644d2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC2NM7AYKENZ0ZD49HAX1W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'ClideCollapserCard excludeSemantics wipes expanded children from the a11y tree', 'lib/widgets/src/clide_collapser_card.dart:92-101 — excludeSemantics: true on the card wipes every expanded child from the a11y tree: a screen-reader user can expand a run/tool card and hear nothing inside it. A11y is a Tier-0 contract in this repo, so this is a contract breach, not polish.
Fix: exclude semantics only while collapsed (or scope the exclusion to the chrome, not the body); keep the header announcing expanded/collapsed state.
Acceptance: semantics test asserting expanded-card children are present in the semantics tree and absent (or summarized) when collapsed; make test-a11y green.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:57:04', '2026-06-11 21:57:12', NULL, 'f9c5330a7954c66ad65efea30ac5539f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC46SEHH8NQY481VMGK66R', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'A11y/i18n/theme gates hand-enumerate their subjects and have fallen behind lib/', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:57:17', '2026-06-11 21:57:17', NULL, 'bfb099275caa67eaae14c141f178dd3c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC46SEHH8NQY481VMGK66R', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'A11y/i18n/theme gates hand-enumerate their subjects and have fallen behind lib/', 'The three a11y gate tests hand-enumerate their subjects and have measurably drifted from lib/: the contrast gate checks fewer themes than lib/main.dart:443-454 actually loads (catppuccin is silently unvalidated — the same theme list also drifted between main.dart and the testmode harness), and the i18n gate checks 4 of 8 namespaces. The gates stay green while covering less — worst kind of drift.
Fix (pattern: hand-enumerated lists drift; export the truth): make lib/ export one canonical const each for bundled themes, a11y gate subjects, and i18n namespaces; the gates and the testmode harness iterate those exports instead of their own lists. Add a meta-assertion where feasible (e.g. namespace list derived from the assets dir at test time) so a new theme/namespace cannot ship unvalidated.
Acceptance: gates fail if a newly added theme/namespace is not covered; catppuccin contrast-checked; all 8 i18n namespaces checked; testmode harness consumes the same exported theme list.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:57:17', '2026-06-11 21:57:26', NULL, '290bcc744fe4b90080d3617048018a81', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC5ZE4EZEGXK8YY8J86CM0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'IPC server dispatch is not serial; shared StringBuffer framing can drop/double lines', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:31', '2026-06-11 21:57:31', NULL, '7a2cfcdde5be66cff55589267aa01d54', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC5ZE4EZEGXK8YY8J86CM0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'IPC server dispatch is not serial; shared StringBuffer framing can drop/double lines', 'lib/src/ipc/server.dart:151-180 — D-72 promises serial dispatch, but the async onData handler never pauses the subscription, so pipelined requests interleave; the shared StringBuffer framing can also drop or double lines when chunks split mid-frame, and per-chunk utf8 decode corrupts multi-byte characters split across chunks.
Fix in one move: client.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()) consumed with await for — gives correct framing, persistent UTF-8 decoding, and true serialization at once.
Acceptance: test sending two pipelined requests in a single write (responses arrive in order, both handled); test a request split mid-UTF-8-rune across two socket writes; existing IPC tests stay green. Runs under dart test — keep imports Flutter-free.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:31', '2026-06-11 21:57:40', NULL, '4235607b75073718a6cba068cfde7c42', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC7KDFW07S8WTCC3MD71J0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split-chunk UTF-8 corruption at byte-to-String seams: terminal write(String), FileTailFollower', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:44', '2026-06-11 21:57:44', NULL, '454708a914320a3286f49db1d2ac7319', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC7KDFW07S8WTCC3MD71J0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split-chunk UTF-8 corruption at byte-to-String seams: terminal write(String), FileTailFollower', 'The terminal''s only ingestion API is write(String) (lib/src/terminal/src/terminal.dart:218), so both consumers decode bytes per-chunk — a multi-byte rune split across PTY reads renders as U+FFFD garbage. FileTailFollower starts reading mid-file by construction, so it can begin mid-character too.
Fix: add Terminal.writeBytes(List<int>) backed by a persistent chunked Utf8Decoder (allowMalformed) per terminal instance; migrate the PTY consumer and FileTailFollower to it. Keep write(String) for tests/programmatic use.
Acceptance: test feeding a multi-byte rune split across two writeBytes calls renders one glyph; FileTailFollower starting mid-rune resyncs without emitting replacement chars mid-stream.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:44', '2026-06-11 21:57:52', NULL, '2960aaecae3dfdedf5a660440e0f6d84', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC90B72A270CAKA7AP1ZX8', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Orchestrator.spawn() races itself — concurrent spawns for one id leak a live claude process', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:56', '2026-06-11 21:57:56', NULL, 'e2db785a676eadbf9dd3b78a655853f3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC90B72A270CAKA7AP1ZX8', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Orchestrator.spawn() races itself — concurrent spawns for one id leak a live claude process', 'lib/builtin/claude/src/session_orchestrator.dart:191-249 — spawn() does check-then-act on the sessions map across two awaits (transcript-tail read, process start). Two concurrent spawns for the same session id both pass the check; the loser''s live claude process is orphaned, never killed, never observed.
Fix: hold a Map<String, Future<ManagedSession>> — first caller installs the future synchronously, later callers await the same future; remove the entry on failure.
Acceptance: test issuing two concurrent spawn() calls for one id yields the same ManagedSession instance and exactly one process spawn (count via injected spawner); failure path clears the in-flight entry so a retry can proceed.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:56', '2026-06-11 21:58:03', NULL, '79ae3a9297cb05e9afb8c22a01bf4ea4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCAFKK334YNJXZJQG4J6AW', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Fork panes: widget.forkSourceId wins forever — /clear re-forks the original conversation', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:08', '2026-06-11 21:58:08', NULL, '95294be3de8244f6177eb3bfc1f89abb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCAFKK334YNJXZJQG4J6AW', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Fork panes: widget.forkSourceId wins forever — /clear re-forks the original conversation', 'lib/builtin/claude/src/claude_pane.dart:266-281 — when (re)binding a session, widget.forkSourceId takes precedence forever, so /clear in a fork pane re-forks the original conversation instead of clearing, and /resume and /fork misbehave the same way. Related context: clide owns /clear, /resume, /compact interception (T-156); panes pin a session id.
Fix: treat forkSourceId as a one-shot spawn parameter — consume it on first bind (clear it into pane state), so subsequent session-mutating commands operate on the pane''s live session.
Acceptance: test that a fork pane after /clear starts an empty session (no fork source passed to the orchestrator on respawn); first bind still forks from the source.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:08', '2026-06-11 21:58:16', NULL, '7ee9fdb25f18eee232aa4c9a968ea22c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCC6AR37VTF4SY8DR99JHC', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Settings persistence corrupts maps-inside-lists on write; non-atomic; parse failure silently resets', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:22', '2026-06-11 21:58:22', NULL, '21145621090d4acc5bbd8502de4e218e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCC6AR37VTF4SY8DR99JHC', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Settings persistence corrupts maps-inside-lists on write; non-atomic; parse failure silently resets', 'lib/kernel/src/settings.dart:199-219 — the writer emits toString() for maps nested inside lists, corrupting them on the next read; this breaks the documented keymap overlay across restarts. Writes are also non-atomic (a crash mid-write truncates the file), and a parse failure on load silently resets ALL settings instead of preserving the file and surfacing the error.
Fix: serialize with a real encoder (JSON/YAML emitter, whatever the file format is) covering nested structures; write to a temp file + rename for atomicity; on parse failure keep the original file (e.g. move aside as .broken) and log via the kernel Logger instead of resetting.
Acceptance: round-trip test for a keymap overlay (list of maps) across save/load; simulated partial write leaves previous settings intact; corrupt file does not silently reset and produces a logged diagnostic.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:22', '2026-06-11 21:58:33', NULL, 'c1f63e92d9c3708ee09c54a1b3ec9010', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCEC25337J2AXXQNST56Y4', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Extension activation is not transactional; disable ignores dependents; registries clobber on id collision', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:40', '2026-06-11 21:58:40', NULL, '3989745ea0ab1fbca279115f5e9a5088', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCEC25337J2AXXQNST56Y4', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Extension activation is not transactional; disable ignores dependents; registries clobber on id collision', 'lib/kernel/src/extensions_manager.dart:133-192 — a throw mid-contribution leaves earlier contributions mounted while the extension records as failed; a retry then double-applies them. Also: disabling an extension ignores extensions that depend on it, and contribution registries silently clobber on id collision. Benign among curated builtins; hazardous the day Tier-6 Lua extensions (T-8) land.
Fix: make activation transactional — collect contributions, mount only after the extension activates cleanly, and unwind mounted ones on failure; disable refuses (or cascades, pick one and record it) when dependents are active; registries reject or namespace duplicate ids with a logged diagnostic.
Acceptance: test that an extension throwing mid-activation leaves zero contributions mounted and can retry cleanly; disable-with-dependents behaves per the chosen rule; duplicate contribution id surfaces an error instead of clobbering.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:40', '2026-06-11 21:58:49', NULL, '48334c3389949474ff1f341534126001', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCG84F4SPFW111CC4K26A8', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal conformance debt: HTS no-op, DECCKM unconsumed, mouse rows off-by-one, CPR 0-based, scrollback unreachable', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:55', '2026-06-11 21:58:55', NULL, 'a4f9ba0068e58b70a3567e1704dc1053', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCG84F4SPFW111CC4K26A8', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal conformance debt: HTS no-op, DECCKM unconsumed, mouse rows off-by-one, CPR 0-based, scrollback unreachable', 'The owned terminal fork fixes what it trips over but has no vttest-style conformance suite. Verified debt:
- HTS (set tab stop) is a no-op: calls isSetAt instead of setAt (lib/src/terminal/src/terminal.dart:423).
- DECCKM (cursor-keys application mode) is tracked but never consumed when encoding arrow-key input.
- Legacy X10/X11 mouse reporting rows are off-by-one AND the existing test enshrines the bug — fix both together.
- CPR (cursor position report) replies 0-based where every real terminal is 1-based.
- Scrollback is maintained but structurally unreachable: ViewportOffset.zero() is pinned on every build (lib/src/terminal/src/terminal_view.dart:224), so no scrolling UI path exists. (Blocks the semantic-terminal feature idea; fix is a prerequisite there.)
Approach: fix each item with a focused conformance test (vttest-style expectations); consider starting a small conformance_test.dart suite that future escape-sequence work extends. Coordinate with T-123 (parser split) and T-369 (SGR crash) which touch the same area.
Acceptance: each of the five items has a test that fails on the old behavior and passes after; mouse test corrected, not deleted.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:55', '2026-06-11 21:59:05', NULL, '0a7b313f101913232e0de3d528aa8be7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCJEYHC91PMVNVWVHBR2RG', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Markdown renderer: hard breaks and images render as empty text', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:13', '2026-06-11 21:59:13', NULL, '73cdfc05da40b52f2d8712f589ba3734', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCJEYHC91PMVNVWVHBR2RG', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Markdown renderer: hard breaks and images render as empty text', 'lib/widgets/src/clide_markdown.dart:408-410 — hard line breaks and image nodes both fall through to empty text spans: words on either side of a hard break glue together, and images vanish entirely (no placeholder, no alt text).
Fix: emit a newline span for hard breaks; render images as at least an alt-text placeholder chip (full image rendering can be a follow-up — note the existing feedback that live-pane images go through clide image show).
Acceptance: golden/widget test for hard-break line splitting; image node renders alt text; no regression in existing markdown goldens.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:13', '2026-06-11 21:59:20', NULL, 'eec49027f1c3e355070f5e36d0fcbc72', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCM1RBAF72SCZBKRTXJSYC', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Streaming render path is O(n²): full re-parse + sync existsSync in build, O(n) re-derive per notify, full re-encode per delta', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:26', '2026-06-11 21:59:26', NULL, 'aa0a2a2ca4769d495fb48ae91d7340bd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCM1RBAF72SCZBKRTXJSYC', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Streaming render path is O(n²): full re-parse + sync existsSync in build, O(n) re-derive per notify, full re-encode per delta', 'Three compounding costs on every streamed token:
1. The whole markdown document re-parses inside build on each streaming delta, including sync existsSync() calls in the parse path (clide_markdown.dart) — sync I/O on the UI isolate per frame.
2. The conversation view re-derives its full item list O(n) on every controller notification.
3. Token streaming re-encodes the full reply text per delta (lib/builtin/claude/src/stream_json_session.dart:410-431) — O(n²) churn over a long reply.
Fix directions: cache parsed markdown per card keyed by content hash and only re-parse the tail/dirty card; move file-existence link checks off the build path (async + cache); make the session accumulate deltas in a StringBuffer instead of string concat re-encode; derive conversation items incrementally. Profile before/after with a long synthetic reply.
Acceptance: a 1000-delta synthetic stream does no existsSync in build (assert via injected fs seam or profiling harness), and per-delta work is bounded (no full-document re-parse); existing rendering tests green.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:26', '2026-06-11 21:59:37', NULL, 'ea41886fb8c4e6768980bacea594b182', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCP03EJ9CDBGZGRPD19N8W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal panes spawn in Directory.current, not the open project root', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:42', '2026-06-11 21:59:42', NULL, 'fbee567d26a6c3bd5ee068ffbdd92a8d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCP03EJ9CDBGZGRPD19N8W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal panes spawn in Directory.current, not the open project root', 'lib/builtin/terminal/src/terminal_pane.dart:69 — the shell spawns with Directory.current as cwd. Launched from a desktop entry, that is $HOME, not the open workspace; after a project switch it is whatever the process started in. SpawnSpec.cwd already exists in the PTY layer.
Fix: pass the active workspace root as the spawn cwd (and re-derive it on project switch for new panes).
Acceptance: test that a terminal pane''s SpawnSpec.cwd equals the workspace root, not Directory.current.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:42', '2026-06-11 21:59:50', NULL, 'cec13ab104ecf333fdf67ebbfc09a067', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCQHZQ0NKY1VRWWPSNZT84', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Notifications service renders nowhere — warnings vanish into an unrendered list', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:55', '2026-06-11 21:59:55', NULL, 'f25cab7fb88724093d582cc9d72e3976', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCQHZQ0NKY1VRWWPSNZT84', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Notifications service renders nowhere — warnings vanish into an unrendered list', 'lib/kernel/src/notify.dart has zero widget consumers — anything pushed through the Notifications service (e.g. cli_install''s dogfood warnings) accumulates in a list no surface renders. ToastService exists right next to it and does render.
Fix options (pick one, note it on this ticket): (a) route notify-level messages through ToastService with severity styling; (b) add a notifications tray/indicator surface; (c) delete the service and migrate callers to toasts. Option (a) or (c) is likely right for current scale — avoid building a tray nobody asked for.
Acceptance: a notification posted by cli_install is visibly surfaced in the UI (test via whichever surface is chosen); no silent sink remains.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:55', '2026-06-11 22:00:05', NULL, '7fd6794ef2583587f29d082af5796bac', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCST6CQ449VJGAP6C5KZ5W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Welcome screen no-ops: Clone-from-git and Start-a-Claude-session tiles do nothing; advertised shortcuts don''t exist', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:00:14', '2026-06-11 22:00:14', NULL, '8b87dd014b7e1925a59c3f9e0f9869ef', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCST6CQ449VJGAP6C5KZ5W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Welcome screen no-ops: Clone-from-git and Start-a-Claude-session tiles do nothing; advertised shortcuts don''t exist', 'lib/builtin/welcome/src/welcome_view.dart:173-174 — the Clone-from-git and Start-a-Claude-session tiles are inert on tap, and the keyboard shortcuts printed on the tiles are not registered anywhere. First-run users hit dead UI on the first screen.
Fix: either wire the tiles (clone flow; open a Claude pane) and register the shortcuts through the keymap subsystem, or remove the tiles until the flows exist — no advertised dead ends. Note: the welcome screen also duplicates FileActions'' open-folder flow verbatim; the dedup is covered by the copy-paste sweep ticket under this epic, but if you touch this file, prefer calling into FileActions.
Acceptance: every tile on the welcome screen performs its action (widget test taps each); every shortcut shown is registered in the keymap.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:00:14', '2026-06-11 22:00:22', NULL, '8c66b985baf074abdc23d761ce10abae', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCVPGCKEGDC54KKQ120SRM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'make test-e2e / ui-dev / ui-smoke are dead (tools/ui cd into removed app/); staged Gitea CI would fail on activation', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:00:29', '2026-06-11 22:00:29', NULL, '40c383af82388001daad2e99ee9d046e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCVPGCKEGDC54KKQ120SRM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'make test-e2e / ui-dev / ui-smoke are dead (tools/ui cd into removed app/); staged Gitea CI would fail on activation', 'tools/ui/*.sh still cd into the removed app/ directory, so make test-e2e, make ui-dev, and make ui-smoke fail immediately. The staged Gitea CI workflow would fail in three independent ways the day it is activated, while D-32 describes it as ready.
Fix: repoint the scripts at the repo root (post app/-flattening layout), run each target to prove it, and walk the Gitea workflow steps locally (or in a dry-run) until each step is green or consciously removed. Amend D-32 if the CI story changed.
Acceptance: all three make targets run; the workflow file''s steps each map to a working make target; D-32 matches reality.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:00:29', '2026-06-11 22:00:37', NULL, 'c519d8d5e20db12785db272799738638', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCXFF5V1RT6QJETS2K4C0G', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Rat extermination: dead-code sweep (legacy git API, ToolCheck, libc, GraphView, ColumnHat, tmux-era team pipeline, ptyc binary, mocktail)', NULL, 'backlog', 'low', NULL, NULL, NULL, '2026-06-11 22:00:44', '2026-06-11 22:00:44', NULL, '940a9f2b3f061d4701f2eb86cb37f10e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCXFF5V1RT6QJETS2K4C0G', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Rat extermination: dead-code sweep (legacy git API, ToolCheck, libc, GraphView, ColumnHat, tmux-era team pipeline, ptyc binary, mocktail)', 'Verified-dead code worth one sweep (coverage denominator benefits too):
- Legacy free-function git API (~250 LOC duplicating GitClient, kept alive only by its own tests, and carrying its own latent pipe-deadlock bug) — delete API + tests.
- ToolCheck — zero callers.
- ~60% of lib/src/pty/ffi/libc.dart — fd-passing-era bindings unused since D-56.
- GraphView — unreachable placeholder (note: the Governance Graph idea (see Q-records from this review) may later want the slot; deleting now is still right, it is a 17-line stub).
- ColumnHat — duplicated line-for-line in app.dart, kept alive by a zero-coverage test; the app.dart split ticket removes the duplicate, this sweep removes the orphan.
- tmux-era team pipeline: TranscriptPublisher, TeamMemberJoined — nothing emits these events, yet the team roster UI listens to them exclusively (team tiles are populated by ghosts). Remove pipeline + dead listeners; if the roster UI stays, it needs a real data source first (surface that before deleting the UI).
- Dead ptyc binary still committed in native/linux-x64/ against D-62/D-63 — remove binary + licenses.yaml entry if present.
- mocktail — pinned, documented in D-25 as the IO-mocking strategy, imported by zero files: either adopt it where mocks are hand-rolled or drop the dep AND amend D-25.
Each bullet is one commit. Run make test + coverage after each; expect the floor to ratchet up.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-11 22:00:44', '2026-06-11 22:01:02', NULL, '7eda667d2a552b742eb9a8d7d6069f0c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHD098CV2N73823KX4Z99P4', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'ValueStream (replay-latest) wrapper; retrofit statusStream, busyStream, pendingPromptStream', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 22:01:07', '2026-06-11 22:01:07', NULL, '0fc9a635cb4884d5974e8675baff028e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHD098CV2N73823KX4Z99P4', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'ValueStream (replay-latest) wrapper; retrofit statusStream, busyStream, pendingPromptStream', 'Broadcast streams that carry STATE (not events) drop the current value for late subscribers. This one shape caused T-274 (status bar blank — root cause appended there), the meta sidebar''s manual compensation, and the prompt-stream''s initialData workaround.
Fix: write one small ValueStream<T> wrapper (a broadcast stream that replays the latest value to each new subscriber, plus a .value getter) in the kernel; retrofit statusStream, busyStream, and pendingPromptStream in the claude builtin; delete the per-site workarounds it obsoletes. No third-party dep (rxdart) — prefer-zero-deps; the wrapper is ~30 LOC.
Acceptance: unit tests for the wrapper (late subscriber gets latest value; no value yet = no synthetic emit unless seeded); T-274 repro covered: subscribing after the init event still yields the status. Closing this should make T-274 fixable in one line at the call site.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 22:01:07', '2026-06-11 22:01:18', NULL, '3dd1b6a453892cb3c99e1fedb51cf371', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHD2FWTDFYA00W4QXTE41M0', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Ban catch (_) {} on I/O and lifecycle paths — sweep and log through kernel Logger', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:01:25', '2026-06-11 22:01:25', NULL, '7d028bfb1fd47d81c63b33e9ea931864', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHD2FWTDFYA00W4QXTE41M0', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Ban catch (_) {} on I/O and lifecycle paths — sweep and log through kernel Logger', 'The silent-swallow idiom turned an illegal-lookup-in-dispose into two resource leaks (T-366) and turned process-spawn failures into blank panes. Rule to apply: cleanup/teardown paths MAY swallow (with a comment saying why); spawn, read, and dispose-adjacent paths MUST log through the kernel Logger they already have access to.
Work: grep the tree for `catch (_)` and empty catch bodies; classify each site (cleanup vs load-bearing); add logging or rethrow on the load-bearing ones; leave a one-line justification comment on the legitimate swallows. Consider an analysis_options lint (avoid_catches_without_on_clauses or a custom assist) only if it can be enabled without suppressions — per repo policy no lint suppressions without approval.
Acceptance: zero unjustified empty catches on spawn/read/dispose paths; each remaining swallow carries a why-comment; any new logging visible in the kernel log during testmode.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:01:25', '2026-06-11 22:01:37', NULL, '0831ea558c5ebf30d44b8a5b3167a4ee', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHD4QYHYRTK0SGRZCBSHSQ0', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Decide and apply the sync-I/O-in-async-handlers rule (new D-record; offload above a size threshold)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:01:43', '2026-06-11 22:01:43', NULL, 'bd614146db5d02385cf948333a489b99', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHD4QYHYRTK0SGRZCBSHSQ0', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Decide and apply the sync-I/O-in-async-handlers rule (new D-record; offload above a size threshold)', 'The single-isolate app does sync I/O inside async IPC handlers: files.read does a sync 10MB read; the replace engine reads and rewrites workspace files synchronously — while grep right next to it fans out to isolates per D-79. There is no recorded rule, so each new handler guesses.
Work: claim a D-record (pql decisions claim D architecture "sync I/O policy in IPC handlers") deciding the rule — suggested: async File APIs by default in handlers; offload to an isolate above N KB (align N with the D-79 grep design); sync allowed only in pure-Dart test seams. Then apply it to files.read and replace_engine, citing the new D-NNN at each site.
Acceptance: D-record confirmed; files.read and search.replace no longer block the UI isolate on large files (test with a multi-MB fixture asserting the event loop stays responsive, e.g. a timer keeps firing).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:01:43', '2026-06-11 22:01:52', NULL, 'b0dfc4e32215d3500f671bce4342b965', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHD6FQMXMQQQ877KMNCK6QC', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Move path confinement to the dispatch layer, keyed off the co-registered schema', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:01:57', '2026-06-11 22:01:57', NULL, '369ef0dc4b4fa905a8858388bce405e3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHD6FQMXMQQQ877KMNCK6QC', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Move path confinement to the dispatch layer, keyed off the co-registered schema', 'Per-verb path confinement is a lottery: files.read remembered, search.replace half-remembered, editor.open/save forgot (T-363). The dispatcher registry (D-74) already co-registers a schema with every handler and the schema already knows which params are paths.
Fix: add a path-type marker to the schema layer (or derive from a naming convention recorded in the D-record amendment) and run path_safety confinement once in the dispatcher before the handler sees the request. Per-verb checks become defense-in-depth or get deleted.
Do after T-363 lands its point fix — this is the structural follow-up that prevents the next forgotten verb. Amend D-74 with the confinement rule.
Acceptance: a registered verb with a path param automatically rejects traversal/escape without any handler code; a regression test registers a synthetic verb and proves confinement applies; existing verbs unchanged behavior for in-workspace paths.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:01:57', '2026-06-11 22:02:08', NULL, '41cf2ffe1acca214218b6804b73ae191', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHD8F2NBEFZNPKSJ9W253J0', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Copy-paste dedup sweep: welcome/FileActions, palette/quick-open twins, three tail-followers, five _userErr, five git sandboxes, two ANSI flag enums', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:02:14', '2026-06-11 22:02:14', NULL, '445a9ac5a5b22f8d2efce7116a08a8ae', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHD8F2NBEFZNPKSJ9W253J0', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Copy-paste dedup sweep: welcome/FileActions, palette/quick-open twins, three tail-followers, five _userErr, five git sandboxes, two ANSI flag enums', 'Verified duplication clusters, each small, together the repo''s main rot vector:
- Welcome screen clones FileActions'' entire open-folder flow verbatim — call FileActions instead (coordinate with T-383).
- Command palette and quick-open are ~230-line near-twins — extract the shared list-overlay+filter core.
- Three private tail-a-growing-file implementations in the claude builtin alone — one shared follower (coordinate with T-373, which gives it the chunked decoder).
- Five hand-rolled _userErr helpers across handlers — one shared error-shaping helper.
- Five copy-pasted git test sandboxes, none isolating host git config (set GIT_CONFIG_GLOBAL/HOME in the shared fixture — flaky-test risk today).
- Two parallel ANSI flag enums that already drifted: strikethrough is stored but never painted — unify, then paint strikethrough.
One commit per cluster. Acceptance: each cluster has a single implementation with the call sites migrated and a test guarding the shared piece; git sandboxes isolated from host config.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:02:14', '2026-06-11 22:02:25', NULL, '5f5034f42fcf5741805d8d4ef81e6376', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDAK04ZA0PBT69ZWNBXPSR', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'claude builtin returns ok with an error payload in 16 handlers — honor the D-6 exit-code contract', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:02:31', '2026-06-11 22:02:31', NULL, '6aaf2b8af3b6596bcf35b13bcdc0a2b5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDAK04ZA0PBT69ZWNBXPSR', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'claude builtin returns ok with an error payload in 16 handlers — honor the D-6 exit-code contract', '16 handlers in the claude builtin return result: ok with an error field in the payload instead of an error envelope, drifting from the D-6 exit-code contract every other subsystem honors. Scripted example: clide claude.agent.set-permission-mode bogus exits 0 today, so scripts cannot detect failure.
Fix: sweep the claude builtin handlers; on failure return the error envelope (non-zero CLI exit) like the rest of the dispatcher. Audit callers/UI that may currently rely on ok-with-error.
Acceptance: clide claude.agent.set-permission-mode bogus exits non-zero; a table-driven test walks the claude verbs'' failure paths asserting error envelopes; D-6 conformance restored.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:02:31', '2026-06-11 22:02:40', NULL, '55e8cdd77883a14348bbb7e6f288529c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDC7B2MFQZVR1K9E30FXDR', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Docs drift at the front door: CLAUDE.md/README tmux claim superseded by D-75/D-77; README version + stub features overstated', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:02:44', '2026-06-11 22:02:44', NULL, '731e453c28c5fe59ebefdd9719e69d76', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDC7B2MFQZVR1K9E30FXDR', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Docs drift at the front door: CLAUDE.md/README tmux claim superseded by D-75/D-77; README version + stub features overstated', 'CLAUDE.md and README still say "tmux owns Claude session persistence (D-41)" — superseded by D-75/D-77 per docs/architecture.md. README says "Pre-v2.0 (2.0.0-dev)" while pubspec is at v2.3.3, and headlines "canvas and graph surfaces" that are a 17-line stub and a flat ListView respectively (T-7 epic was cancelled).
Fix: rewrite the stale paragraphs in both files to match current architecture (clide-managed stream-json sessions); fix the README version line (or derive it); demote canvas/graph to roadmap wording or drop them. The repo''s honesty is its brand; the README is the one off-brand surface.
Acceptance: no tmux-persistence claim outside historical D-records; README version matches pubspec; every README feature claim maps to shipped behavior.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:02:44', '2026-06-11 22:02:54', NULL, 'b4ab7763b2e791c559777dfa83c78fa3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDE5A3965GNRFS5WPZW878', 'story', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Close the release loop: back-tag 2.2.02.3.3, fix ci/release.sh, add tagging to the release ritual, widen the fast-path regex', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 22:03:00', '2026-06-11 22:03:00', NULL, '4efa9cc5cf36800ac88e9da767a20654', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDE5A3965GNRFS5WPZW878', 'story', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Close the release loop: back-tag 2.2.02.3.3, fix ci/release.sh, add tagging to the release ritual, widen the fast-path regex', 'No git tag since v2.1.0 despite five CHANGELOG releases; ci/release.sh exits 64 and still references the dissolved sidecar; the pre-push fast path''s safety argument cites "release CI on tagged versions" that does not exist; and the fast-path regex skips ALL tests for pushes touching test/, ci/, or the hook itself.
Work items (separable commits):
1. Back-tag v2.2.0 through v2.3.3 at the release-cut commits (find them via the CHANGELOG version-bump commits).
2. Rewrite ci/release.sh for the single-process architecture or delete it and fold release steps into the Makefile — either way, no stub that exits 64.
3. Add tagging to the release ritual in .claude/skills/git-commit/SKILL.md (version bump + changelog move + tag in one documented step).
4. Widen the pre-push fast-path regex so changes under test/, ci/, and the hook itself run the full gate.
This is also the blocking prerequisite the T-47 (self-update) refinement identified. Acceptance: git tag lists every released version; release.sh (or its replacement) runs end-to-end; fast-path regex covered by a hook test if feasible.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 22:03:00', '2026-06-11 22:03:10', NULL, '223b9403ea9923579214e54b184969eb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDGPXQN31NNRPJ00PFRAG4', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split lib/app.dart (1187 LOC, five concerns)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:03:21', '2026-06-11 22:03:21', NULL, 'adf4ca4c52a805c0c686a18b317e5e08', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDH8TE9MJBXZ4GQ5YT10JM', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split lib/builtin/claude/src/claude_meta_sidebar.dart (1192 LOC)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:03:26', '2026-06-11 22:03:26', NULL, 'a0a11f91eda4b9041a5b059416cdb719', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDGPXQN31NNRPJ00PFRAG4', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split lib/app.dart (1187 LOC, five concerns)', 'lib/app.dart is 1187 LOC mixing five confirmed concerns. Split plan (verified against the file 2026-06-12):
Inventory: ClideApp (14-29), _AppRoot (31-45), _RootShell/_RootShellState keyboard+intent routing (47-229), RootLayout (231-350), hat bar family _HatBar/_LeftHatContent/_RightHatContent/_WinBtn (352-439), project switcher family _ProjectSwitcherButton/_ProjectSwitcherDropdown/_RecentProjectRow/_ActionRow (441-672), SlotHost/_SlotHostState/_SlotBody/_SidebarSlot/_WorkspaceSlot/_RevealedTab/_ContextSlot (674-1030), _BottomRail (1032-1078), StatusbarCollapseToggle (1093-1126), StatusbarHost (1128-1170), _EditorDragHandle (924-1010), _WelcomeOverlay (1172-1187).
Target layout:
- app.dart keeps ClideApp + _AppRoot and RE-EXPORTS the public symbols so tests keep importing package:clide/app.dart.
- lib/widgets/root_shell.dart: _RootShell/_RootShellState (keyboard routing; depends on ModifierTapTracker, MenuBarController).
- lib/builtin/hat/hat_bar.dart: _HatBar, _LeftHatContent, _RightHatContent, _WinBtn, hatHeight.
- lib/builtin/hat/project_switcher.dart: the switcher family (~230 LOC).
- lib/widgets/slot_host.dart: SlotHost + slot bodies (NOTE: _RevealedTab references _SlotBody._resolveTitle — keep them together or extract the helper).
- lib/widgets/layout_status.dart: RootLayout internals, StatusbarHost, StatusbarCollapseToggle, _BottomRail, _EditorDragHandle(+Intent), _WelcomeOverlay.
Order: mechanical first (hat bar, switcher rows, welcome overlay), then root shell, then slot host (tangled: _SlotHostState registers focus scopes via ClideKernel.of in didChangeDependencies), then layout/status.
Tests importing app.dart: test/app_test.dart (RootLayout, StatusbarHost, ClideApp), test/app_statusbar_test.dart (StatusbarHost), test/app_collapse_toggle_test.dart (StatusbarCollapseToggle) — re-exports keep them unchanged.
CORRECTION to fable-ous.md: ColumnHat is NOT duplicated line-for-line in app.dart (verified); ColumnHat lives only in lib/widgets/src/clide_column_hat.dart. The rat-sweep ticket T-385 was annotated accordingly — verify whether ColumnHat is dead before deleting.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:03:21', '2026-06-11 22:09:19', NULL, 'ea4c03fb65ff1d332c15ed7df7a865e3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCXFF5V1RT6QJETS2K4C0G', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Rat extermination: dead-code sweep (legacy git API, ToolCheck, libc, GraphView, ColumnHat, tmux-era team pipeline, ptyc binary, mocktail)', 'Verified-dead code worth one sweep (coverage denominator benefits too):
- Legacy free-function git API (~250 LOC duplicating GitClient, kept alive only by its own tests, and carrying its own latent pipe-deadlock bug) — delete API + tests.
- ToolCheck — zero callers.
- ~60% of lib/src/pty/ffi/libc.dart — fd-passing-era bindings unused since D-56.
- GraphView — unreachable placeholder (note: the Governance Graph idea (see Q-records from this review) may later want the slot; deleting now is still right, it is a 17-line stub).
- ColumnHat — duplicated line-for-line in app.dart, kept alive by a zero-coverage test; the app.dart split ticket removes the duplicate, this sweep removes the orphan.
- tmux-era team pipeline: TranscriptPublisher, TeamMemberJoined — nothing emits these events, yet the team roster UI listens to them exclusively (team tiles are populated by ghosts). Remove pipeline + dead listeners; if the roster UI stays, it needs a real data source first (surface that before deleting the UI).
- Dead ptyc binary still committed in native/linux-x64/ against D-62/D-63 — remove binary + licenses.yaml entry if present.
- mocktail — pinned, documented in D-25 as the IO-mocking strategy, imported by zero files: either adopt it where mocks are hand-rolled or drop the dep AND amend D-25.
Each bullet is one commit. Run make test + coverage after each; expect the floor to ratchet up.
Correction (2026-06-12, verified during T-394 breakdown): ColumnHat is NOT duplicated line-for-line in app.dart — it exists only in lib/widgets/src/clide_column_hat.dart. Before deleting it, verify it actually has zero non-test callers; if it is genuinely used by app chrome, drop that bullet from this sweep.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-11 22:00:44', '2026-06-11 22:09:30', NULL, 'dd3a3f6ec9a78df6044d44713218e989', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDH8TE9MJBXZ4GQ5YT10JM', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split lib/builtin/claude/src/claude_meta_sidebar.dart (1192 LOC)', 'lib/builtin/claude/src/claude_meta_sidebar.dart is 1192 LOC. Split plan (verified against the file 2026-06-12). Public API (ClaudeMetaSidebar widget + SidebarTab enum) stays in the root file — tests and extension.dart need zero import changes.
Inventory: consts _labelColumnWidth/_rowPitch (43-44), SidebarTab enum (46), ClaudeMetaSidebar (48-79), _ClaudeMetaSidebarState monolith (81-638: lifecycle, stats polling, team membership streams, primary-session binding, broker subscription, config listener, inject state, accordion state, plus the three tab bodies), _ConfigSection/_ConfigPermKind enums (641/644), _MetaSection/_MetaRow models (646-657), _AgentRosterRow(+State) (680-928), _permissionModeBadge + _PermissionModeBadge T-181 (935-1009), _IconButton (1012-1039), _InjectTextField (1043-1070), _TaskRow T-171 (1077-1141), _TabStrip (1145-1192).
Target layout under lib/builtin/claude/src/meta_sidebar/: models.dart (enums + _MetaSection/_MetaRow + layout consts), activity_tab.dart (ActivityTabView ~70 LOC, from _activityBody/_runtimeSection 272-302), team_tab.dart (TeamTabView ~120 LOC, from _teamBody/_taskSection 304-380, props-driven with callbacks), config_tab.dart (ConfigTabView ~200 LOC, from _configBody family 382-597; accordion _expanded state stays in parent, passed as prop+callback), roster_row.dart (_AgentRosterRow ~250 LOC incl. bypass-confirm state), permission_badge.dart (~80), task_row.dart (~70), tab_strip.dart (~55), icon_button.dart + inject_field.dart (~30 each — keep here initially; promote to lib/widgets/ only when a second consumer appears). Root file shrinks to ~150 LOC of lifecycle + event bindings + tab switch.
Execution order (each phase independently green): 1) primitives (icon button, inject field, permission badge, models); 2) stateless tab views (activity, config, tab strip) with prop threading; 3) team tab + roster row (most orchestrator coupling: verify show/hide, mute, inject submit-and-clear, shift-click bypass confirm, fork, close, task reassign cycle, auto-front on TeamMemberJoined at line 158); 4) cleanup of moved methods from the root state.
Tests: test/builtin/claude/claude_meta_sidebar_test.dart imports only the root file and public symbols — no changes needed; no goldens reference the sidebar. Caveat from the rat sweep (T-385): TeamMemberJoined currently has no emitter — coordinate before investing in the team tab plumbing.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:03:26', '2026-06-11 22:09:55', NULL, '555ee8c21aa930e2310712747c9b2d97', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4RJ9K19P5AX14RHRR', 'task', '06FB0TNQM4HGK6KR0314P2M32M', 'split lib/src/terminal/.../escape/parser.dart (1139 LOC)', 'parser.dart is a single 1139-line file containing the full ESC/CSI/OSC/DCS handler tree for the terminal emulator. Functional but unwieldy; the consultant flagged it as ''consider splitting'' in the T-107 review.
Suggested split (sequenced with the T-91 coverage sweep on lib/src/terminal/, so the split doesn''t fight in-flight test work):
- parser.dart — entry point + state machine driver
- esc_handlers.dart — single-char ESC dispatch table + handlers
- csi_handlers.dart — CSI parameter parsing + handlers
- osc_handlers.dart — OSC string handlers (title, colour set, etc.)
- dcs_handlers.dart — DCS/SOS/PM/APC string handlers
Each handler module exports a registrar that the driver wires at construction.
Done when:
- parser.dart < 400 LOC
- All existing parser tests pass without changes
- No new public surface; everything stays library-private
Source: T-107 / consultants.md "Code quality — Findings — [Major]".
Split breakdown from the 2026-06-11 Fable review (epic T-359), verified against the file 2026-06-12:
Structure today: main parser + routing (11-116), CSI core _escHandleCSI (196-209) + _consumeCsi (217-272), _csiHandlers table of 27 final bytes (274-304), cursor movement handlers (306-807), erase/scroll/line/char ops (809-945), SGR monolith (411-622, 212 LOC), mode set/reset (395-409, 946-1030), DA/DSR (334-343, 624-635), window manipulation (658-712), OSC (1034-1110), _Csi state object (1113-1131 — note the commented-out `intermediates` field at 1117/1125).
Target layout under escape/: parser.dart keeps EscapeParser (queue, tokenization, top-level dispatch, ~400 LOC); csi_parser.dart (CsiSequence value object — prefix, params, RESTORED intermediates, finalByte — plus the consume logic from _consumeCsi); csi_handlers.dart (dispatch table, now keyed on final byte + intermediates); cursor_handlers.dart; sgr_handler.dart (the 411-622 monolith); mode_handler.dart; osc_parser.dart + osc_handlers.dart. EscapeHandler interface unchanged. Preserve the zero-allocation/reset-able design goal noted at lines 14-16.
Bug this split must fix (verified): _consumeCsi DISCARDS intermediate bytes — lines 258-261 have `// intermediates.add(char);` commented out and `continue`, so CSI Ps SP q (DECSCUSR, cursor style) and CSI ! p / SP-intermediate forms dispatch on the bare final byte and fall to unknownCSI. Fix: restore the intermediates field on CsiSequence, capture them during consume, dispatch on (intermediates, finalByte), and add EscapeHandler.setCursorStyle for DECSCUSR. (DECSTR is CSI ! p — soft terminal reset — same intermediate mechanism.)
Related bug with its own ticket (T-369): unguarded params[i+1] lookahead in SGR 38/48 at lines ~502/512/547/557 (RangeError on truncated sequences) + colon-form sub-parameters unhandled. The split makes the fix natural: sgr_handler.dart owns guarded lookahead helpers; if T-369 lands first, carry its tests over; if this lands first, fix it inside sgr_handler.dart and close T-369 with it.
Tests: test/terminal/escape/parser_test.dart (786 LOC) splits along the same seams — keep parser_test.dart for top-level dispatch/SBC/rollback, add csi_parser_test.dart (intermediates capture, DECSCUSR), sgr_handler_test.dart (bounds + colon form + 256/RGB), mode_handler_test.dart, osc_parser_test.dart, window/DA splits as convenient. The _RecordingHandler fixture is reusable across all of them.', 'backlog', 'low', NULL, NULL, NULL, '2026-05-18 10:29:02', '2026-06-11 22:10:19', NULL, '90450ed5bd162fe65bf0426662d65ee4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBJ5T7HAQ9CA8XQMX43A2C', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'PTY master fd leaks on every natural child exit', 'lib/src/pty/native_pty.dart:444-450 — on child EOF, _reap() sets _dead = true but never closes _fd; a later close() short-circuits at `if (_dead) return;` (line ~460) so _nativeClose(_fd) (line ~477) never runs. Every terminal/Claude pane whose child exits on its own leaks an fd and a pty device for the life of the app. Two independent reviewers confirmed.
Fix: close the master fd in the natural-exit path (or let close() proceed to fd teardown when dead). Add a test asserting the fd is released after child EOF.', 'in_progress', 'critical', NULL, NULL, NULL, '2026-06-11 21:54:49', '2026-06-11 22:14:48', NULL, '3356972c53b2ba4079a3c86e7d836c09', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBJ5T7HAQ9CA8XQMX43A2C', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'PTY master fd leaks on every natural child exit', 'lib/src/pty/native_pty.dart:444-450 — on child EOF, _reap() sets _dead = true but never closes _fd; a later close() short-circuits at `if (_dead) return;` (line ~460) so _nativeClose(_fd) (line ~477) never runs. Every terminal/Claude pane whose child exits on its own leaks an fd and a pty device for the life of the app. Two independent reviewers confirmed.
Fix: close the master fd in the natural-exit path (or let close() proceed to fd teardown when dead). Add a test asserting the fd is released after child EOF.', 'done', 'critical', NULL, NULL, NULL, '2026-06-11 21:54:49', '2026-06-11 22:18:25', NULL, '62168ec7a6bcdd154adc173c9cf9275f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBV0465906BY3QFAY9F1YM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Kernel lookup in dispose() leaks shell PTYs and settings listeners', 'lib/builtin/terminal/src/terminal_pane.dart:131-137 calls ClideKernel.of(context) from dispose() — illegal ancestor lookup, swallowed by catch (_) — so pane.close is never sent and the backend PTY + daemon pane leak on every closed terminal pane. The same idiom leaks the settings listener in every disposed ClaudePane (lib/builtin/claude/src/claude_pane.dart:460-466).
Fix: cache the kernel ref in didChangeDependencies, delete the catch-alls. Combined with the PTY natural-exit fd leak this is a two-stage leak pipeline. Tests: closing a terminal pane sends pane.close; disposing a ClaudePane removes its settings listener.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:56:01', '2026-06-11 22:18:56', NULL, 'a6c200ce295ef6e192688761f664ac9d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBV0465906BY3QFAY9F1YM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Kernel lookup in dispose() leaks shell PTYs and settings listeners', 'lib/builtin/terminal/src/terminal_pane.dart:131-137 calls ClideKernel.of(context) from dispose() — illegal ancestor lookup, swallowed by catch (_) — so pane.close is never sent and the backend PTY + daemon pane leak on every closed terminal pane. The same idiom leaks the settings listener in every disposed ClaudePane (lib/builtin/claude/src/claude_pane.dart:460-466).
Fix: cache the kernel ref in didChangeDependencies, delete the catch-alls. Combined with the PTY natural-exit fd leak this is a two-stage leak pipeline. Tests: closing a terminal pane sends pane.close; disposing a ClaudePane removes its settings listener.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:56:01', '2026-06-11 22:24:07', NULL, '167f8b01f1fcf2ef0bcc845585ca17aa', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Conversation auto-scroll yanks a scrolled-up reader to the bottom on every streamed token', 'lib/builtin/claude/src/conversation_view.dart:268-277 — the _atBottom pin exists but is only consulted on viewport resize, not on new items. Anyone reading earlier output during a long streaming reply is dragged to the bottom continuously.
Fix: gate the new-item auto-scroll on _atBottom (one-line) and add the missing twin test: scrolled-up viewport stays put when items stream in; at-bottom viewport follows.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:56:33', '2026-06-11 22:24:34', NULL, '2cf4e96fedd8de400239157f53ac0b94', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Conversation auto-scroll yanks a scrolled-up reader to the bottom on every streamed token', 'lib/builtin/claude/src/conversation_view.dart:268-277 — the _atBottom pin exists but is only consulted on viewport resize, not on new items. Anyone reading earlier output during a long streaming reply is dragged to the bottom continuously.
Fix: gate the new-item auto-scroll on _atBottom (one-line) and add the missing twin test: scrolled-up viewport stays put when items stream in; at-bottom viewport follows.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:56:33', '2026-06-11 22:26:15', NULL, '1340fb7aac3cdf06476ce74248fda6c0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal throws RangeError on truncated SGR 38/48; colon sub-parameters mangled', 'lib/src/terminal/src/core/escape/parser.dart:501-516 — SGR 38/48 extended-color parsing does unguarded params[i + 1] lookahead, so a truncated sequence like printf ''\e[38m'' throws RangeError inside Terminal.write. An emulator must never throw on hostile bytes. Secondary, same code path: colon-form SGR sub-parameters (e.g. 38:2:r:g:b, emitted by modern terminfo) are not split out and get mangled into bogus params.
Fix: bounds-check the lookahead (ignore incomplete 38/48 sequences), and parse colon-form sub-parameters per ECMA-48/ITU T.416 — treat 38:2::r:g:b and 38;2;r;g;b equivalently. Note T-123 (parser split) touches the same file; coordinate but do not block on it.
Acceptance: feeding any truncated/garbled SGR byte sequence never throws (fuzz-style test over partial sequences); colon-form truecolor sets the same fg/bg as semicolon form; existing SGR tests stay green.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:56:47', '2026-06-11 22:26:38', NULL, '8cc428e286b918b46744a649ab1979f0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal throws RangeError on truncated SGR 38/48; colon sub-parameters mangled', 'lib/src/terminal/src/core/escape/parser.dart:501-516 — SGR 38/48 extended-color parsing does unguarded params[i + 1] lookahead, so a truncated sequence like printf ''\e[38m'' throws RangeError inside Terminal.write. An emulator must never throw on hostile bytes. Secondary, same code path: colon-form SGR sub-parameters (e.g. 38:2:r:g:b, emitted by modern terminfo) are not split out and get mangled into bogus params.
Fix: bounds-check the lookahead (ignore incomplete 38/48 sequences), and parse colon-form sub-parameters per ECMA-48/ITU T.416 — treat 38:2::r:g:b and 38;2;r;g;b equivalently. Note T-123 (parser split) touches the same file; coordinate but do not block on it.
Acceptance: feeding any truncated/garbled SGR byte sequence never throws (fuzz-style test over partial sequences); colon-form truecolor sets the same fg/bg as semicolon form; existing SGR tests stay green.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:56:47', '2026-06-11 22:30:24', NULL, 'b64b55f7d40149848fc687efe1cf8f32', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBSG6356MZJ2DCCCSBMBGM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'listDir symlink detection is dead code; walkFiles descends symlinked dirs', 'lib/src/files/listing.dart:46-54 — stat() follows links, so isSymlink is always false; walkFiles therefore descends symlinked directories the docs claim it skips (escape hatch out of the workspace, plus cycle risk).
Fix: use lstat (FileStat via Link check / FileSystemEntity.isLinkSync on the raw path) for symlink detection. Tests: symlinked dir is reported as symlink and not descended; symlink cycle does not hang the walk.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:55:49', '2026-06-11 22:30:48', NULL, 'aa0f09e575dd2d3232f34485982c1ec5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBSG6356MZJ2DCCCSBMBGM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'listDir symlink detection is dead code; walkFiles descends symlinked dirs', 'lib/src/files/listing.dart:46-54 — stat() follows links, so isSymlink is always false; walkFiles therefore descends symlinked directories the docs claim it skips (escape hatch out of the workspace, plus cycle risk).
Fix: use lstat (FileStat via Link check / FileSystemEntity.isLinkSync on the raw path) for symlink detection. Tests: symlinked dir is reported as symlink and not descended; symlink cycle does not hang the walk.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:55:49', '2026-06-11 22:32:08', NULL, 'f83e31fd330c98a311a3da10cba1c655', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBR4636GSRJBWFJDAZ6ZA0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'search.replace silently ignores its include/exclude globs', 'lib/src/search/replace_engine.dart:124-143 — the include/exclude glob filters the user typed are accepted but never applied; replace will happily rewrite files outside the filter.
Fix: apply the same glob filtering the search side uses before rewriting. Test: replace with an include glob touches only matching files; exclude glob is honored.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:55:38', '2026-06-11 22:32:29', NULL, '0d067ce0eded0fcc158d09ea793bb223', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBR4636GSRJBWFJDAZ6ZA0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'search.replace silently ignores its include/exclude globs', 'lib/src/search/replace_engine.dart:124-143 — the include/exclude glob filters the user typed are accepted but never applied; replace will happily rewrite files outside the filter.
Fix: apply the same glob filtering the search side uses before rewriting. Test: replace with an include glob touches only matching files; exclude glob is honored.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:55:38', '2026-06-11 22:34:31', NULL, 'e86c024d6f8a6d9cd9b7273522b54511', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBPQE4J4YBJX92812ZK6DR', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'editor.open/editor.save skip path confinement entirely', 'lib/src/editor/registry.dart:215-219 returns absolute paths verbatim — no .. normalization, no path_safety call — an unconfined read AND write primitive over IPC while files.read is carefully guarded.
Fix: route editor.open/editor.save through path_safety like files.*. Tests: traversal and absolute-escape attempts rejected for both verbs. Longer-term the confinement should move to the dispatch layer (see the systemic ticket filed with this epic).', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:55:26', '2026-06-11 22:34:51', NULL, 'ff2c4e85e11c02c88eff6f8e87348d34', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBPQE4J4YBJX92812ZK6DR', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'editor.open/editor.save skip path confinement entirely', 'lib/src/editor/registry.dart:215-219 returns absolute paths verbatim — no .. normalization, no path_safety call — an unconfined read AND write primitive over IPC while files.read is carefully guarded.
Fix: route editor.open/editor.save through path_safety like files.*. Tests: traversal and absolute-escape attempts rejected for both verbs. Longer-term the confinement should move to the dispatch layer (see the systemic ticket filed with this epic).', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:55:26', '2026-06-11 22:39:04', NULL, '50c42cb7af7715e9ea6417212be0ebdc', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBWE2W1226T58CX37E50HC', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Project switch leaks the entire previous workspace service set', 'lib/main.dart:335-344 — switching projects builds a new dispatcher with fresh PaneRegistry, FilesService, EditorRegistry, etc., but nothing calls the old set''s shutdown() methods (which exist and have zero callers). Old file watchers keep emitting into the new workspace''s bus.
Fix: dispose/shutdown the previous service set before (or while) standing up the new one. Test: after a workspace switch, the old FilesService watcher no longer delivers events.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:56:13', '2026-06-11 22:39:32', NULL, 'c0fdac5acbf4332f8d2a632edbc94085', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBWE2W1226T58CX37E50HC', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Project switch leaks the entire previous workspace service set', 'lib/main.dart:335-344 — switching projects builds a new dispatcher with fresh PaneRegistry, FilesService, EditorRegistry, etc., but nothing calls the old set''s shutdown() methods (which exist and have zero callers). Old file watchers keep emitting into the new workspace''s bus.
Fix: dispose/shutdown the previous service set before (or while) standing up the new one. Test: after a workspace switch, the old FilesService watcher no longer delivers events.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:56:13', '2026-06-11 22:44:05', NULL, 'ab0fad47ab342a3578b9fec64dd886e7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC2NM7AYKENZ0ZD49HAX1W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'ClideCollapserCard excludeSemantics wipes expanded children from the a11y tree', 'lib/widgets/src/clide_collapser_card.dart:92-101 — excludeSemantics: true on the card wipes every expanded child from the a11y tree: a screen-reader user can expand a run/tool card and hear nothing inside it. A11y is a Tier-0 contract in this repo, so this is a contract breach, not polish.
Fix: exclude semantics only while collapsed (or scope the exclusion to the chrome, not the body); keep the header announcing expanded/collapsed state.
Acceptance: semantics test asserting expanded-card children are present in the semantics tree and absent (or summarized) when collapsed; make test-a11y green.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:57:04', '2026-06-11 22:44:30', NULL, 'fa9b28e5617b452b34d21b58d67ed3ca', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC2NM7AYKENZ0ZD49HAX1W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'ClideCollapserCard excludeSemantics wipes expanded children from the a11y tree', 'lib/widgets/src/clide_collapser_card.dart:92-101 — excludeSemantics: true on the card wipes every expanded child from the a11y tree: a screen-reader user can expand a run/tool card and hear nothing inside it. A11y is a Tier-0 contract in this repo, so this is a contract breach, not polish.
Fix: exclude semantics only while collapsed (or scope the exclusion to the chrome, not the body); keep the header announcing expanded/collapsed state.
Acceptance: semantics test asserting expanded-card children are present in the semantics tree and absent (or summarized) when collapsed; make test-a11y green.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:57:04', '2026-06-11 22:46:46', NULL, '1a642a72ded99e1710d9cd5e4b73a591', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBKK2TZQK683J8FS0ZH5A4', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Claude child process observed only via stdout: stderr never drained, exit never watched', 'lib/builtin/claude/src/stream_json_session.dart:43-78 — the claude child stderr is never drained: >=64KB of --verbose spew fills the pipe, the child blocks mid-turn, and the flagship pane wedges with zero diagnostics. Nothing watches exitCode or stdout onDone (line ~303), so a crashed/dead session just looks busy.
Fix: drain stderr into a bounded ring buffer (surface it on failure), watch exitCode/onDone, and emit a terminal SessionEnded state to the pane. Intersects T-283 (resume hang has no timeout/fallback). Tests: dead-child surfaces SessionEnded; stderr flood does not wedge the session.', 'in_progress', 'critical', NULL, NULL, NULL, '2026-06-11 21:55:01', '2026-06-11 22:47:12', NULL, '050a42aae5477de3d6013adfc6231814', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBKK2TZQK683J8FS0ZH5A4', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Claude child process observed only via stdout: stderr never drained, exit never watched', 'lib/builtin/claude/src/stream_json_session.dart:43-78 — the claude child stderr is never drained: >=64KB of --verbose spew fills the pipe, the child blocks mid-turn, and the flagship pane wedges with zero diagnostics. Nothing watches exitCode or stdout onDone (line ~303), so a crashed/dead session just looks busy.
Fix: drain stderr into a bounded ring buffer (surface it on failure), watch exitCode/onDone, and emit a terminal SessionEnded state to the pane. Intersects T-283 (resume hang has no timeout/fallback). Tests: dead-child surfaces SessionEnded; stderr flood does not wedge the session.', 'done', 'critical', NULL, NULL, NULL, '2026-06-11 21:55:01', '2026-06-11 22:53:45', NULL, '225dafdd282850fa3fc420acb0084355', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6K1QRC911JMQ9C960', 'bug', '06FB0TNQM5HM3N07VB3RPFJCFM', 'Resume hang has no timeout/fallback; resume decided by file-exists not content', 'Surfaced 2026-06-08 while chasing a --resume hang in the Claude pane (T-274 diagnostic line). The specific corrupted-transcript repro may turn out to be a one-off, but the code trace found two real latent gaps that make a resume hang unrecoverable regardless of root cause.
(a) No timeout / no fallback on the init-event wait.
On spawn the orchestrator listens for the session_id from claude''s init event (StreamJsonSession sessionIdResolved -> session_orchestrator.dart ~257) with NO timeout. If ''claude --resume <id>'' never emits an init event (hangs), the listener never fires: the pane shows ''resumed · <id>'' + running indicator forever, the process is never killed, and there is no fallback to a fresh --session-id spawn. Unrecoverable without killing the process / restarting clide.
(b) Resume is decided by file existence, not resumable content.
claude_pane.dart ~279: ''final resume = await File(transcriptFile).exists();'' — resume=true purely because the .jsonl exists. A metadata-only transcript (only system / permission-mode / attachment records; parseTranscriptChunk().items returns []) yields resume=true with zero seeded items. So clide passes --resume against a functionally empty session, the diagnostic logs ''fresh session (no history)'' (it keys off seeded count, not the resume flag — itself misleading), and if that resume hangs there is no fallback per (a).
Acceptance:
1. If a --resume spawn yields no init event within a short timeout (e.g. 10-30s), fall back to a fresh --session-id spawn (or surface a recoverable error with a retry affordance) — the pane must never spin forever with no recovery.
2. Don''t pass --resume for a transcript that has no resumable conversation items: validate parsed item count (not just file existence) before choosing --resume vs --session-id, and/or detect-and-repair a metadata-only transcript.
3. The T-274 diagnostic log reflects the actual spawn mode (resume vs fresh), not just seeded-item count.
4. Tests: (i) fake process that never emits init -> pane falls back / surfaces error within the timeout; (ii) metadata-only transcript -> spawn chooses fresh, not --resume.
Cross-refs: T-274 (resumed-session status bar empty), T-167/T-185 (resume/fork session id capture), D-77, claude_pane.dart:279/300-308, session_orchestrator.dart:240/257.
UPDATE 2026-06-08: the active hang did NOT reproduce — clide is running fine inside the 31b214bd primary session (this very session resumes cleanly). So the original break was a one-off (likely the single corrupted transcript), not a live resume bug. This ticket stands as defensive hardening only: the two gaps (no init-event timeout/fallback; resume keyed off file-exists not content) are real but latent — they''d only bite again if a resume genuinely stalls or a metadata-only transcript appears. Lowering to low priority.
T-361 (done, 2026-06-12) added the session-level building blocks this ticket can reuse: StreamJsonSession now watches the process exit code (SessionEnd with stderr tail, replay-latest via session.end) and the pane surfaces ''claude exited (code N) — /clear to restart''. A resume that dies at spawn now surfaces instead of hanging silently; what remains here is the timeout/fallback for a resume that starts but never produces the init event, and resume-decided-by-content.', 'backlog', 'medium', NULL, NULL, 'D-77', '2026-06-08 13:39:51', '2026-06-11 22:53:52', NULL, '06cc0a2e629ee7a192d24286f58af27f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBN5F0F8SDF15P21DNKT1W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'MCP HTTP server exposes the full dispatcher with zero auth', 'lib/src/ipc/mcp_server.dart:138-195, started unconditionally at boot (lib/main.dart:174-180). D-71 threat model (another user on the same host must not drive my IDE) is enforced with 0600 on the unix socket — then bypassed wholesale by an unauthenticated localhost HTTP port that, since D-86, serves every clide verb as a tool.
Fix: generate a token in the lock file (Claude Code /ide lock format has a slot for it) and require the auth header on every request. Tests: request without token is rejected; token round-trips via the lock file.', 'in_progress', 'critical', NULL, NULL, NULL, '2026-06-11 21:55:13', '2026-06-11 22:54:18', NULL, '320e3e3eeb46d8a70e5ecfd5c903563d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBN5F0F8SDF15P21DNKT1W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'MCP HTTP server exposes the full dispatcher with zero auth', 'lib/src/ipc/mcp_server.dart:138-195, started unconditionally at boot (lib/main.dart:174-180). D-71 threat model (another user on the same host must not drive my IDE) is enforced with 0600 on the unix socket — then bypassed wholesale by an unauthenticated localhost HTTP port that, since D-86, serves every clide verb as a tool.
Fix: generate a token in the lock file (Claude Code /ide lock format has a slot for it) and require the auth header on every request. Tests: request without token is rejected; token round-trips via the lock file.', 'done', 'critical', NULL, NULL, NULL, '2026-06-11 21:55:13', '2026-06-11 22:57:42', NULL, '14de71ed19e921a370001097887a7e6f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHD098CV2N73823KX4Z99P4', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'ValueStream (replay-latest) wrapper; retrofit statusStream, busyStream, pendingPromptStream', 'Broadcast streams that carry STATE (not events) drop the current value for late subscribers. This one shape caused T-274 (status bar blank — root cause appended there), the meta sidebar''s manual compensation, and the prompt-stream''s initialData workaround.
Fix: write one small ValueStream<T> wrapper (a broadcast stream that replays the latest value to each new subscriber, plus a .value getter) in the kernel; retrofit statusStream, busyStream, and pendingPromptStream in the claude builtin; delete the per-site workarounds it obsoletes. No third-party dep (rxdart) — prefer-zero-deps; the wrapper is ~30 LOC.
Acceptance: unit tests for the wrapper (late subscriber gets latest value; no value yet = no synthetic emit unless seeded); T-274 repro covered: subscribing after the init event still yields the status. Closing this should make T-274 fixable in one line at the call site.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 22:01:07', '2026-06-11 23:01:24', NULL, '490fa0773537afc3ec2be0756960dc05', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5W6VN98RQM6S22X28', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Claude bottom status bar often stays empty / doesn''t update', 'The bottom status-bar context slot for the Claude pane (model · permission-mode · context, T-145/T-150) frequently renders empty and doesn''t update. The slot is fed by the active pane''s status widget via the focus service (PaneContextStatusItem -> ClidePane.statusWidget in claude_pane.dart), sourced from StreamJsonSession.statusStream (model/permissionMode from the ''system/init'' event; cost/contextWindow from ''result'' events in stream_json_session.dart).
Repro: open clide; the status line is often blank and stays blank until/unless a turn completes (or never populates).
Likely suspects to investigate:
- status only published while the pane is the focused contribution (active==true) — if focus isn''t on the Claude pane, the slot clears.
- statusStream may not emit until the first ''result''/''init'' event; a resumed session (--resume) may not re-emit init, so model/mode never arrive.
- _statusWidget returns null when _status.isEmpty AND no skills, so an unstarted/!init session shows nothing.
- focus-slot wiring (FocusTracker) may not re-publish on pane (re)build / session rebind.
Acceptance: the Claude status line shows model · mode · context promptly after a session starts/resumes and stays current across turns and focus changes; add a test covering the resumed-session (no fresh init) case.
Root cause found and verified by the 2026-06-11 Fable review (fable-ous.md, epic T-359): statusStream is a plain broadcast controller — the system/init event fires while spawn() is still awaiting a 256KB transcript-tail read, before the pane ever subscribes (lib/builtin/claude/src/claude_pane.dart:322, lib/builtin/claude/src/session_orchestrator.dart:222-225). Fix: seed from session.status on bind, or make the stream replay-latest (see the ValueStream systemic ticket under T-359).', 'done', 'medium', NULL, NULL, NULL, '2026-06-08 07:26:13', '2026-06-11 23:04:46', NULL, '97024e51051e9ac66e65376e953a5faf', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHD098CV2N73823KX4Z99P4', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'ValueStream (replay-latest) wrapper; retrofit statusStream, busyStream, pendingPromptStream', 'Broadcast streams that carry STATE (not events) drop the current value for late subscribers. This one shape caused T-274 (status bar blank — root cause appended there), the meta sidebar''s manual compensation, and the prompt-stream''s initialData workaround.
Fix: write one small ValueStream<T> wrapper (a broadcast stream that replays the latest value to each new subscriber, plus a .value getter) in the kernel; retrofit statusStream, busyStream, and pendingPromptStream in the claude builtin; delete the per-site workarounds it obsoletes. No third-party dep (rxdart) — prefer-zero-deps; the wrapper is ~30 LOC.
Acceptance: unit tests for the wrapper (late subscriber gets latest value; no value yet = no synthetic emit unless seeded); T-274 repro covered: subscribing after the init event still yields the status. Closing this should make T-274 fixable in one line at the call site.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 22:01:07', '2026-06-11 23:04:46', NULL, '9cd3d1777cfbe56c117a91aa3f303855', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCP03EJ9CDBGZGRPD19N8W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal panes spawn in Directory.current, not the open project root', 'lib/builtin/terminal/src/terminal_pane.dart:69 — the shell spawns with Directory.current as cwd. Launched from a desktop entry, that is $HOME, not the open workspace; after a project switch it is whatever the process started in. SpawnSpec.cwd already exists in the PTY layer.
Fix: pass the active workspace root as the spawn cwd (and re-derive it on project switch for new panes).
Acceptance: test that a terminal pane''s SpawnSpec.cwd equals the workspace root, not Directory.current.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:42', '2026-06-11 23:05:16', NULL, '09bb1689e960768202cfe42d30caf567', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCP03EJ9CDBGZGRPD19N8W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal panes spawn in Directory.current, not the open project root', 'lib/builtin/terminal/src/terminal_pane.dart:69 — the shell spawns with Directory.current as cwd. Launched from a desktop entry, that is $HOME, not the open workspace; after a project switch it is whatever the process started in. SpawnSpec.cwd already exists in the PTY layer.
Fix: pass the active workspace root as the spawn cwd (and re-derive it on project switch for new panes).
Acceptance: test that a terminal pane''s SpawnSpec.cwd equals the workspace root, not Directory.current.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:42', '2026-06-11 23:06:26', NULL, '8cad068c28a7a0cb6772a5fbdcbc34c3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC90B72A270CAKA7AP1ZX8', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Orchestrator.spawn() races itself — concurrent spawns for one id leak a live claude process', 'lib/builtin/claude/src/session_orchestrator.dart:191-249 — spawn() does check-then-act on the sessions map across two awaits (transcript-tail read, process start). Two concurrent spawns for the same session id both pass the check; the loser''s live claude process is orphaned, never killed, never observed.
Fix: hold a Map<String, Future<ManagedSession>> — first caller installs the future synchronously, later callers await the same future; remove the entry on failure.
Acceptance: test issuing two concurrent spawn() calls for one id yields the same ManagedSession instance and exactly one process spawn (count via injected spawner); failure path clears the in-flight entry so a retry can proceed.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:56', '2026-06-11 23:07:01', NULL, 'f2df2148315332d48e2bf66cc2942bd3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC90B72A270CAKA7AP1ZX8', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Orchestrator.spawn() races itself — concurrent spawns for one id leak a live claude process', 'lib/builtin/claude/src/session_orchestrator.dart:191-249 — spawn() does check-then-act on the sessions map across two awaits (transcript-tail read, process start). Two concurrent spawns for the same session id both pass the check; the loser''s live claude process is orphaned, never killed, never observed.
Fix: hold a Map<String, Future<ManagedSession>> — first caller installs the future synchronously, later callers await the same future; remove the entry on failure.
Acceptance: test issuing two concurrent spawn() calls for one id yields the same ManagedSession instance and exactly one process spawn (count via injected spawner); failure path clears the in-flight entry so a retry can proceed.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:56', '2026-06-11 23:08:13', NULL, 'fde3f9e912ea1d25256003ef56dcf491', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCAFKK334YNJXZJQG4J6AW', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Fork panes: widget.forkSourceId wins forever — /clear re-forks the original conversation', 'lib/builtin/claude/src/claude_pane.dart:266-281 — when (re)binding a session, widget.forkSourceId takes precedence forever, so /clear in a fork pane re-forks the original conversation instead of clearing, and /resume and /fork misbehave the same way. Related context: clide owns /clear, /resume, /compact interception (T-156); panes pin a session id.
Fix: treat forkSourceId as a one-shot spawn parameter — consume it on first bind (clear it into pane state), so subsequent session-mutating commands operate on the pane''s live session.
Acceptance: test that a fork pane after /clear starts an empty session (no fork source passed to the orchestrator on respawn); first bind still forks from the source.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:08', '2026-06-11 23:08:40', NULL, '8e8a63e0617c3707db1cf5621f181b63', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCAFKK334YNJXZJQG4J6AW', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Fork panes: widget.forkSourceId wins forever — /clear re-forks the original conversation', 'lib/builtin/claude/src/claude_pane.dart:266-281 — when (re)binding a session, widget.forkSourceId takes precedence forever, so /clear in a fork pane re-forks the original conversation instead of clearing, and /resume and /fork misbehave the same way. Related context: clide owns /clear, /resume, /compact interception (T-156); panes pin a session id.
Fix: treat forkSourceId as a one-shot spawn parameter — consume it on first bind (clear it into pane state), so subsequent session-mutating commands operate on the pane''s live session.
Acceptance: test that a fork pane after /clear starts an empty session (no fork source passed to the orchestrator on respawn); first bind still forks from the source.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:08', '2026-06-11 23:10:56', NULL, '1cd70120bb752815eeaebb58e57b1a1c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC5ZE4EZEGXK8YY8J86CM0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'IPC server dispatch is not serial; shared StringBuffer framing can drop/double lines', 'lib/src/ipc/server.dart:151-180 — D-72 promises serial dispatch, but the async onData handler never pauses the subscription, so pipelined requests interleave; the shared StringBuffer framing can also drop or double lines when chunks split mid-frame, and per-chunk utf8 decode corrupts multi-byte characters split across chunks.
Fix in one move: client.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()) consumed with await for — gives correct framing, persistent UTF-8 decoding, and true serialization at once.
Acceptance: test sending two pipelined requests in a single write (responses arrive in order, both handled); test a request split mid-UTF-8-rune across two socket writes; existing IPC tests stay green. Runs under dart test — keep imports Flutter-free.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:31', '2026-06-11 23:11:24', NULL, '03c848a027a4ae292e854398358eb150', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC5ZE4EZEGXK8YY8J86CM0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'IPC server dispatch is not serial; shared StringBuffer framing can drop/double lines', 'lib/src/ipc/server.dart:151-180 — D-72 promises serial dispatch, but the async onData handler never pauses the subscription, so pipelined requests interleave; the shared StringBuffer framing can also drop or double lines when chunks split mid-frame, and per-chunk utf8 decode corrupts multi-byte characters split across chunks.
Fix in one move: client.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()) consumed with await for — gives correct framing, persistent UTF-8 decoding, and true serialization at once.
Acceptance: test sending two pipelined requests in a single write (responses arrive in order, both handled); test a request split mid-UTF-8-rune across two socket writes; existing IPC tests stay green. Runs under dart test — keep imports Flutter-free.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:31', '2026-06-11 23:13:18', NULL, 'bbc0aa50303eac50666cbcfa140065a1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCC6AR37VTF4SY8DR99JHC', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Settings persistence corrupts maps-inside-lists on write; non-atomic; parse failure silently resets', 'lib/kernel/src/settings.dart:199-219 — the writer emits toString() for maps nested inside lists, corrupting them on the next read; this breaks the documented keymap overlay across restarts. Writes are also non-atomic (a crash mid-write truncates the file), and a parse failure on load silently resets ALL settings instead of preserving the file and surfacing the error.
Fix: serialize with a real encoder (JSON/YAML emitter, whatever the file format is) covering nested structures; write to a temp file + rename for atomicity; on parse failure keep the original file (e.g. move aside as .broken) and log via the kernel Logger instead of resetting.
Acceptance: round-trip test for a keymap overlay (list of maps) across save/load; simulated partial write leaves previous settings intact; corrupt file does not silently reset and produces a logged diagnostic.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:22', '2026-06-11 23:13:49', NULL, '71635348a33e10c188ea10581c39ca57', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCC6AR37VTF4SY8DR99JHC', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Settings persistence corrupts maps-inside-lists on write; non-atomic; parse failure silently resets', 'lib/kernel/src/settings.dart:199-219 — the writer emits toString() for maps nested inside lists, corrupting them on the next read; this breaks the documented keymap overlay across restarts. Writes are also non-atomic (a crash mid-write truncates the file), and a parse failure on load silently resets ALL settings instead of preserving the file and surfacing the error.
Fix: serialize with a real encoder (JSON/YAML emitter, whatever the file format is) covering nested structures; write to a temp file + rename for atomicity; on parse failure keep the original file (e.g. move aside as .broken) and log via the kernel Logger instead of resetting.
Acceptance: round-trip test for a keymap overlay (list of maps) across save/load; simulated partial write leaves previous settings intact; corrupt file does not silently reset and produces a logged diagnostic.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:22', '2026-06-11 23:16:28', NULL, 'e23aad60b9f75e50cb305e727254e094', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCJEYHC91PMVNVWVHBR2RG', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Markdown renderer: hard breaks and images render as empty text', 'lib/widgets/src/clide_markdown.dart:408-410 — hard line breaks and image nodes both fall through to empty text spans: words on either side of a hard break glue together, and images vanish entirely (no placeholder, no alt text).
Fix: emit a newline span for hard breaks; render images as at least an alt-text placeholder chip (full image rendering can be a follow-up — note the existing feedback that live-pane images go through clide image show).
Acceptance: golden/widget test for hard-break line splitting; image node renders alt text; no regression in existing markdown goldens.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:13', '2026-06-11 23:16:53', NULL, 'e3bf2108898cf82d75be7a0da17443ca', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCJEYHC91PMVNVWVHBR2RG', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Markdown renderer: hard breaks and images render as empty text', 'lib/widgets/src/clide_markdown.dart:408-410 — hard line breaks and image nodes both fall through to empty text spans: words on either side of a hard break glue together, and images vanish entirely (no placeholder, no alt text).
Fix: emit a newline span for hard breaks; render images as at least an alt-text placeholder chip (full image rendering can be a follow-up — note the existing feedback that live-pane images go through clide image show).
Acceptance: golden/widget test for hard-break line splitting; image node renders alt text; no regression in existing markdown goldens.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:13', '2026-06-11 23:18:22', NULL, '5ec0ffc2676c2f31476249301ae2869b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCQHZQ0NKY1VRWWPSNZT84', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Notifications service renders nowhere — warnings vanish into an unrendered list', 'lib/kernel/src/notify.dart has zero widget consumers — anything pushed through the Notifications service (e.g. cli_install''s dogfood warnings) accumulates in a list no surface renders. ToastService exists right next to it and does render.
Fix options (pick one, note it on this ticket): (a) route notify-level messages through ToastService with severity styling; (b) add a notifications tray/indicator surface; (c) delete the service and migrate callers to toasts. Option (a) or (c) is likely right for current scale — avoid building a tray nobody asked for.
Acceptance: a notification posted by cli_install is visibly surfaced in the UI (test via whichever surface is chosen); no silent sink remains.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:55', '2026-06-11 23:18:47', NULL, '20850287af7c135adc76d53f200f5209', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCQHZQ0NKY1VRWWPSNZT84', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Notifications service renders nowhere — warnings vanish into an unrendered list', 'lib/kernel/src/notify.dart has zero widget consumers — anything pushed through the Notifications service (e.g. cli_install''s dogfood warnings) accumulates in a list no surface renders. ToastService exists right next to it and does render.
Fix options (pick one, note it on this ticket): (a) route notify-level messages through ToastService with severity styling; (b) add a notifications tray/indicator surface; (c) delete the service and migrate callers to toasts. Option (a) or (c) is likely right for current scale — avoid building a tray nobody asked for.
Acceptance: a notification posted by cli_install is visibly surfaced in the UI (test via whichever surface is chosen); no silent sink remains.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:55', '2026-06-11 23:20:52', NULL, 'fb8aa4e3ae8a67be11cb051975e3804c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCQHZQ0NKY1VRWWPSNZT84', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Notifications service renders nowhere — warnings vanish into an unrendered list', 'lib/kernel/src/notify.dart has zero widget consumers — anything pushed through the Notifications service (e.g. cli_install''s dogfood warnings) accumulates in a list no surface renders. ToastService exists right next to it and does render.
Fix options (pick one, note it on this ticket): (a) route notify-level messages through ToastService with severity styling; (b) add a notifications tray/indicator surface; (c) delete the service and migrate callers to toasts. Option (a) or (c) is likely right for current scale — avoid building a tray nobody asked for.
Acceptance: a notification posted by cli_install is visibly surfaced in the UI (test via whichever surface is chosen); no silent sink remains.
Resolved with option (a): Notifications now takes the kernel MessageBus and publishes every notification to the toast channel (severity-mapped, ''title — message''); the in-memory active list stays for API compatibility. No tray built.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:55', '2026-06-11 23:20:59', NULL, 'e98115731bb2c593bd17ae945a79d260', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCST6CQ449VJGAP6C5KZ5W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Welcome screen no-ops: Clone-from-git and Start-a-Claude-session tiles do nothing; advertised shortcuts don''t exist', 'lib/builtin/welcome/src/welcome_view.dart:173-174 — the Clone-from-git and Start-a-Claude-session tiles are inert on tap, and the keyboard shortcuts printed on the tiles are not registered anywhere. First-run users hit dead UI on the first screen.
Fix: either wire the tiles (clone flow; open a Claude pane) and register the shortcuts through the keymap subsystem, or remove the tiles until the flows exist — no advertised dead ends. Note: the welcome screen also duplicates FileActions'' open-folder flow verbatim; the dedup is covered by the copy-paste sweep ticket under this epic, but if you touch this file, prefer calling into FileActions.
Acceptance: every tile on the welcome screen performs its action (widget test taps each); every shortcut shown is registered in the keymap.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 22:00:14', '2026-06-11 23:21:26', NULL, '7c76dbeb0ea389b836633f9ca5df2e68', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCST6CQ449VJGAP6C5KZ5W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Welcome screen no-ops: Clone-from-git and Start-a-Claude-session tiles do nothing; advertised shortcuts don''t exist', 'lib/builtin/welcome/src/welcome_view.dart:173-174 — the Clone-from-git and Start-a-Claude-session tiles are inert on tap, and the keyboard shortcuts printed on the tiles are not registered anywhere. First-run users hit dead UI on the first screen.
Fix: either wire the tiles (clone flow; open a Claude pane) and register the shortcuts through the keymap subsystem, or remove the tiles until the flows exist — no advertised dead ends. Note: the welcome screen also duplicates FileActions'' open-folder flow verbatim; the dedup is covered by the copy-paste sweep ticket under this epic, but if you touch this file, prefer calling into FileActions.
Acceptance: every tile on the welcome screen performs its action (widget test taps each); every shortcut shown is registered in the keymap.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 22:00:14', '2026-06-11 23:24:31', NULL, '259cc1f4603bb7a7b4101d963ea1a9ab', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCST6CQ449VJGAP6C5KZ5W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Welcome screen no-ops: Clone-from-git and Start-a-Claude-session tiles do nothing; advertised shortcuts don''t exist', 'lib/builtin/welcome/src/welcome_view.dart:173-174 — the Clone-from-git and Start-a-Claude-session tiles are inert on tap, and the keyboard shortcuts printed on the tiles are not registered anywhere. First-run users hit dead UI on the first screen.
Fix: either wire the tiles (clone flow; open a Claude pane) and register the shortcuts through the keymap subsystem, or remove the tiles until the flows exist — no advertised dead ends. Note: the welcome screen also duplicates FileActions'' open-folder flow verbatim; the dedup is covered by the copy-paste sweep ticket under this epic, but if you touch this file, prefer calling into FileActions.
Acceptance: every tile on the welcome screen performs its action (widget test taps each); every shortcut shown is registered in the keymap.
Resolved by removal, not stub flows: the two inert tiles are gone (each returns with its real flow — clone is honorable-mention territory in Q-49), the Open-folder shortcut glyph now matches the actual ctrl+o binding, and the tips card was corrected to six bindings that actually exist (quick open, palette, sidebar/context collapse, find-in-files, focus mode — the old card advertised four bindings that were never registered).', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 22:00:14', '2026-06-11 23:24:39', NULL, '49859fb995fa3e1dbd916a153c651c40', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDAK04ZA0PBT69ZWNBXPSR', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'claude builtin returns ok with an error payload in 16 handlers — honor the D-6 exit-code contract', '16 handlers in the claude builtin return result: ok with an error field in the payload instead of an error envelope, drifting from the D-6 exit-code contract every other subsystem honors. Scripted example: clide claude.agent.set-permission-mode bogus exits 0 today, so scripts cannot detect failure.
Fix: sweep the claude builtin handlers; on failure return the error envelope (non-zero CLI exit) like the rest of the dispatcher. Audit callers/UI that may currently rely on ok-with-error.
Acceptance: clide claude.agent.set-permission-mode bogus exits non-zero; a table-driven test walks the claude verbs'' failure paths asserting error envelopes; D-6 conformance restored.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 22:02:31', '2026-06-11 23:25:05', NULL, 'd51d7435910cc30ef2b8893ae05ba6a1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDAK04ZA0PBT69ZWNBXPSR', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'claude builtin returns ok with an error payload in 16 handlers — honor the D-6 exit-code contract', '16 handlers in the claude builtin return result: ok with an error field in the payload instead of an error envelope, drifting from the D-6 exit-code contract every other subsystem honors. Scripted example: clide claude.agent.set-permission-mode bogus exits 0 today, so scripts cannot detect failure.
Fix: sweep the claude builtin handlers; on failure return the error envelope (non-zero CLI exit) like the rest of the dispatcher. Audit callers/UI that may currently rely on ok-with-error.
Acceptance: clide claude.agent.set-permission-mode bogus exits non-zero; a table-driven test walks the claude verbs'' failure paths asserting error envelopes; D-6 conformance restored.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 22:02:31', '2026-06-11 23:28:30', NULL, 'd3e727b4dd8848404845c9cf5af32891', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC7KDFW07S8WTCC3MD71J0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split-chunk UTF-8 corruption at byte-to-String seams: terminal write(String), FileTailFollower', 'The terminal''s only ingestion API is write(String) (lib/src/terminal/src/terminal.dart:218), so both consumers decode bytes per-chunk — a multi-byte rune split across PTY reads renders as U+FFFD garbage. FileTailFollower starts reading mid-file by construction, so it can begin mid-character too.
Fix: add Terminal.writeBytes(List<int>) backed by a persistent chunked Utf8Decoder (allowMalformed) per terminal instance; migrate the PTY consumer and FileTailFollower to it. Keep write(String) for tests/programmatic use.
Acceptance: test feeding a multi-byte rune split across two writeBytes calls renders one glyph; FileTailFollower starting mid-rune resyncs without emitting replacement chars mid-stream.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:44', '2026-06-11 23:28:58', NULL, 'aa06de288d298b3e4bf6add9e128e0ff', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC7KDFW07S8WTCC3MD71J0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split-chunk UTF-8 corruption at byte-to-String seams: terminal write(String), FileTailFollower', 'The terminal''s only ingestion API is write(String) (lib/src/terminal/src/terminal.dart:218), so both consumers decode bytes per-chunk — a multi-byte rune split across PTY reads renders as U+FFFD garbage. FileTailFollower starts reading mid-file by construction, so it can begin mid-character too.
Fix: add Terminal.writeBytes(List<int>) backed by a persistent chunked Utf8Decoder (allowMalformed) per terminal instance; migrate the PTY consumer and FileTailFollower to it. Keep write(String) for tests/programmatic use.
Acceptance: test feeding a multi-byte rune split across two writeBytes calls renders one glyph; FileTailFollower starting mid-rune resyncs without emitting replacement chars mid-stream.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:44', '2026-06-11 23:31:55', NULL, 'fdf8ae8544253d4ae0b79ab17e0d9003', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCEC25337J2AXXQNST56Y4', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Extension activation is not transactional; disable ignores dependents; registries clobber on id collision', 'lib/kernel/src/extensions_manager.dart:133-192 — a throw mid-contribution leaves earlier contributions mounted while the extension records as failed; a retry then double-applies them. Also: disabling an extension ignores extensions that depend on it, and contribution registries silently clobber on id collision. Benign among curated builtins; hazardous the day Tier-6 Lua extensions (T-8) land.
Fix: make activation transactional — collect contributions, mount only after the extension activates cleanly, and unwind mounted ones on failure; disable refuses (or cascades, pick one and record it) when dependents are active; registries reject or namespace duplicate ids with a logged diagnostic.
Acceptance: test that an extension throwing mid-activation leaves zero contributions mounted and can retry cleanly; disable-with-dependents behaves per the chosen rule; duplicate contribution id surfaces an error instead of clobbering.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:40', '2026-06-11 23:32:21', NULL, '5f245fb27177b40eb5b45c90ff7744c3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCEC25337J2AXXQNST56Y4', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Extension activation is not transactional; disable ignores dependents; registries clobber on id collision', 'lib/kernel/src/extensions_manager.dart:133-192 — a throw mid-contribution leaves earlier contributions mounted while the extension records as failed; a retry then double-applies them. Also: disabling an extension ignores extensions that depend on it, and contribution registries silently clobber on id collision. Benign among curated builtins; hazardous the day Tier-6 Lua extensions (T-8) land.
Fix: make activation transactional — collect contributions, mount only after the extension activates cleanly, and unwind mounted ones on failure; disable refuses (or cascades, pick one and record it) when dependents are active; registries reject or namespace duplicate ids with a logged diagnostic.
Acceptance: test that an extension throwing mid-activation leaves zero contributions mounted and can retry cleanly; disable-with-dependents behaves per the chosen rule; duplicate contribution id surfaces an error instead of clobbering.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:40', '2026-06-11 23:35:05', NULL, 'abc89daa9f0b06a8c29747fc30333b65', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCVPGCKEGDC54KKQ120SRM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'make test-e2e / ui-dev / ui-smoke are dead (tools/ui cd into removed app/); staged Gitea CI would fail on activation', 'tools/ui/*.sh still cd into the removed app/ directory, so make test-e2e, make ui-dev, and make ui-smoke fail immediately. The staged Gitea CI workflow would fail in three independent ways the day it is activated, while D-32 describes it as ready.
Fix: repoint the scripts at the repo root (post app/-flattening layout), run each target to prove it, and walk the Gitea workflow steps locally (or in a dry-run) until each step is green or consciously removed. Amend D-32 if the CI story changed.
Acceptance: all three make targets run; the workflow file''s steps each map to a working make target; D-32 matches reality.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 22:00:29', '2026-06-11 23:35:29', NULL, 'c1460818fcd188185216d5ededdb4c0f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCVPGCKEGDC54KKQ120SRM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'make test-e2e / ui-dev / ui-smoke are dead (tools/ui cd into removed app/); staged Gitea CI would fail on activation', 'tools/ui/*.sh still cd into the removed app/ directory, so make test-e2e, make ui-dev, and make ui-smoke fail immediately. The staged Gitea CI workflow would fail in three independent ways the day it is activated, while D-32 describes it as ready.
Fix: repoint the scripts at the repo root (post app/-flattening layout), run each target to prove it, and walk the Gitea workflow steps locally (or in a dry-run) until each step is green or consciously removed. Amend D-32 if the CI story changed.
Acceptance: all three make targets run; the workflow file''s steps each map to a working make target; D-32 matches reality.
2026-06-12: mechanical fixes done — tools/ui/build.sh and serve.sh repointed at the repo root (post app/-flattening), and the Gitea workflow rewritten to go through make targets with a real coverage run before the coverage gate (it previously cd''d into the removed app/ in every job AND ran coverage_gate with no coverage data). Verified: make ui-dev now reaches the real compiler. Which exposed the deeper break: flutter build web --wasm cannot compile the tree at all — dart:ffi (tree-sitter pivot, native PTY) is unavailable on the wasm target. Whether to fence, park, or drop the web/Playwright surface is a user decision → Q-50 (governance/questions/architecture.md). The workflow''s e2e job is withheld with a pointer to Q-50; make test-e2e/ui-dev/ui-smoke remain blocked on it. Leaving this ticket in review until Q-50 resolves.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 22:00:29', '2026-06-11 23:38:58', NULL, '5d99bf87cf43f2a86380a81104f3e70e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCVPGCKEGDC54KKQ120SRM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'make test-e2e / ui-dev / ui-smoke are dead (tools/ui cd into removed app/); staged Gitea CI would fail on activation', 'tools/ui/*.sh still cd into the removed app/ directory, so make test-e2e, make ui-dev, and make ui-smoke fail immediately. The staged Gitea CI workflow would fail in three independent ways the day it is activated, while D-32 describes it as ready.
Fix: repoint the scripts at the repo root (post app/-flattening layout), run each target to prove it, and walk the Gitea workflow steps locally (or in a dry-run) until each step is green or consciously removed. Amend D-32 if the CI story changed.
Acceptance: all three make targets run; the workflow file''s steps each map to a working make target; D-32 matches reality.
2026-06-12: mechanical fixes done — tools/ui/build.sh and serve.sh repointed at the repo root (post app/-flattening), and the Gitea workflow rewritten to go through make targets with a real coverage run before the coverage gate (it previously cd''d into the removed app/ in every job AND ran coverage_gate with no coverage data). Verified: make ui-dev now reaches the real compiler. Which exposed the deeper break: flutter build web --wasm cannot compile the tree at all — dart:ffi (tree-sitter pivot, native PTY) is unavailable on the wasm target. Whether to fence, park, or drop the web/Playwright surface is a user decision → Q-50 (governance/questions/architecture.md). The workflow''s e2e job is withheld with a pointer to Q-50; make test-e2e/ui-dev/ui-smoke remain blocked on it. Leaving this ticket in review until Q-50 resolves.', 'review', 'medium', NULL, NULL, NULL, '2026-06-11 22:00:29', '2026-06-11 23:39:03', NULL, '15d8c06d3292a0f55f8e44ca369b6699', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCXFF5V1RT6QJETS2K4C0G', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Rat extermination: dead-code sweep (legacy git API, ToolCheck, libc, GraphView, ColumnHat, tmux-era team pipeline, ptyc binary, mocktail)', 'Verified-dead code worth one sweep (coverage denominator benefits too):
- Legacy free-function git API (~250 LOC duplicating GitClient, kept alive only by its own tests, and carrying its own latent pipe-deadlock bug) — delete API + tests.
- ToolCheck — zero callers.
- ~60% of lib/src/pty/ffi/libc.dart — fd-passing-era bindings unused since D-56.
- GraphView — unreachable placeholder (note: the Governance Graph idea (see Q-records from this review) may later want the slot; deleting now is still right, it is a 17-line stub).
- ColumnHat — duplicated line-for-line in app.dart, kept alive by a zero-coverage test; the app.dart split ticket removes the duplicate, this sweep removes the orphan.
- tmux-era team pipeline: TranscriptPublisher, TeamMemberJoined — nothing emits these events, yet the team roster UI listens to them exclusively (team tiles are populated by ghosts). Remove pipeline + dead listeners; if the roster UI stays, it needs a real data source first (surface that before deleting the UI).
- Dead ptyc binary still committed in native/linux-x64/ against D-62/D-63 — remove binary + licenses.yaml entry if present.
- mocktail — pinned, documented in D-25 as the IO-mocking strategy, imported by zero files: either adopt it where mocks are hand-rolled or drop the dep AND amend D-25.
Each bullet is one commit. Run make test + coverage after each; expect the floor to ratchet up.
Correction (2026-06-12, verified during T-394 breakdown): ColumnHat is NOT duplicated line-for-line in app.dart — it exists only in lib/widgets/src/clide_column_hat.dart. Before deleting it, verify it actually has zero non-test callers; if it is genuinely used by app chrome, drop that bullet from this sweep.', 'in_progress', 'low', NULL, NULL, NULL, '2026-06-11 22:00:44', '2026-06-12 00:01:13', NULL, '5232eeb9532e61a3d04e8908d0586a49', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBJAXQHCKHS8ZSDZNM9NH7QM', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Rewire team roster UI to TeamBroker membership; delete ghost TeamMemberJoined/Left events', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 00:11:50', '2026-06-12 00:11:50', NULL, '68babe90a7ccbbc7b554e05243adb9ce', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBJAXQHCKHS8ZSDZNM9NH7QM', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Rewire team roster UI to TeamBroker membership; delete ghost TeamMemberJoined/Left events', 'Split out of the T-385 rat sweep: nothing in production constructs TeamMemberJoined/TeamMemberLeft (verified — only the type definition and tests), yet the team roster surfaces (lib/builtin/claude/src/team_panel_host.dart and the meta sidebar Team tab) populate their member lists EXCLUSIVELY from kernel.events.on<TeamMemberJoined>() — ghost-fed UI. The real membership source exists: TeamBroker (orchestrator.broker, T-170/T-171) tracks members via addMember/removeMember on team spawn/close.
Fix: drive both roster surfaces from TeamBroker membership (expose a listenable roster or change stream on the broker), delete TeamMemberJoined/TeamMemberLeft from kernel events/types.dart, and remove the dead listeners. Coordinate with T-395 (meta sidebar split) — whichever lands second adapts.
Acceptance: spawning a team session through the orchestrator makes the member appear in both surfaces (widget test); the ghost event types are gone from types.dart; no kernel.events team-member subscriptions remain.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 00:11:50', '2026-06-12 00:12:04', NULL, '57f7e568fe7c411fc4b45c6b140d8167', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCXFF5V1RT6QJETS2K4C0G', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Rat extermination: dead-code sweep (legacy git API, ToolCheck, libc, GraphView, ColumnHat, tmux-era team pipeline, ptyc binary, mocktail)', 'Verified-dead code worth one sweep (coverage denominator benefits too):
- Legacy free-function git API (~250 LOC duplicating GitClient, kept alive only by its own tests, and carrying its own latent pipe-deadlock bug) — delete API + tests.
- ToolCheck — zero callers.
- ~60% of lib/src/pty/ffi/libc.dart — fd-passing-era bindings unused since D-56.
- GraphView — unreachable placeholder (note: the Governance Graph idea (see Q-records from this review) may later want the slot; deleting now is still right, it is a 17-line stub).
- ColumnHat — duplicated line-for-line in app.dart, kept alive by a zero-coverage test; the app.dart split ticket removes the duplicate, this sweep removes the orphan.
- tmux-era team pipeline: TranscriptPublisher, TeamMemberJoined — nothing emits these events, yet the team roster UI listens to them exclusively (team tiles are populated by ghosts). Remove pipeline + dead listeners; if the roster UI stays, it needs a real data source first (surface that before deleting the UI).
- Dead ptyc binary still committed in native/linux-x64/ against D-62/D-63 — remove binary + licenses.yaml entry if present.
- mocktail — pinned, documented in D-25 as the IO-mocking strategy, imported by zero files: either adopt it where mocks are hand-rolled or drop the dep AND amend D-25.
Each bullet is one commit. Run make test + coverage after each; expect the floor to ratchet up.
Correction (2026-06-12, verified during T-394 breakdown): ColumnHat is NOT duplicated line-for-line in app.dart — it exists only in lib/widgets/src/clide_column_hat.dart. Before deleting it, verify it actually has zero non-test callers; if it is genuinely used by app chrome, drop that bullet from this sweep.', 'done', 'low', NULL, NULL, NULL, '2026-06-11 22:00:44', '2026-06-12 00:23:13', NULL, '9563a30ce5944315595343d612572909', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCXFF5V1RT6QJETS2K4C0G', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Rat extermination: dead-code sweep (legacy git API, ToolCheck, libc, GraphView, ColumnHat, tmux-era team pipeline, ptyc binary, mocktail)', 'Verified-dead code worth one sweep (coverage denominator benefits too):
- Legacy free-function git API (~250 LOC duplicating GitClient, kept alive only by its own tests, and carrying its own latent pipe-deadlock bug) — delete API + tests.
- ToolCheck — zero callers.
- ~60% of lib/src/pty/ffi/libc.dart — fd-passing-era bindings unused since D-56.
- GraphView — unreachable placeholder (note: the Governance Graph idea (see Q-records from this review) may later want the slot; deleting now is still right, it is a 17-line stub).
- ColumnHat — duplicated line-for-line in app.dart, kept alive by a zero-coverage test; the app.dart split ticket removes the duplicate, this sweep removes the orphan.
- tmux-era team pipeline: TranscriptPublisher, TeamMemberJoined — nothing emits these events, yet the team roster UI listens to them exclusively (team tiles are populated by ghosts). Remove pipeline + dead listeners; if the roster UI stays, it needs a real data source first (surface that before deleting the UI).
- Dead ptyc binary still committed in native/linux-x64/ against D-62/D-63 — remove binary + licenses.yaml entry if present.
- mocktail — pinned, documented in D-25 as the IO-mocking strategy, imported by zero files: either adopt it where mocks are hand-rolled or drop the dep AND amend D-25.
Each bullet is one commit. Run make test + coverage after each; expect the floor to ratchet up.
Correction (2026-06-12, verified during T-394 breakdown): ColumnHat is NOT duplicated line-for-line in app.dart — it exists only in lib/widgets/src/clide_column_hat.dart. Before deleting it, verify it actually has zero non-test callers; if it is genuinely used by app chrome, drop that bullet from this sweep.
Done 2026-06-12 across six commits. Notes: ColumnHat''s file carried the LIVE hatHeight constant (app hat bar + menu bar) — moved to widgets/src/chrome_metrics.dart before deleting the dead widget. TranscriptPublisher class removed; the ClaudeConversation addressing constants stay (still consumed). The TeamMemberJoined ghost-event rewiring is real work, split out as T-396. mocktail dropped with D-25 amended (hand-rolled fakes throughout). ptyc binary untracked+deleted (no licenses.yaml entry existed). Coverage rose 95.03% → 95.13% with the dead denominator gone; full push-check green.', 'done', 'low', NULL, NULL, NULL, '2026-06-11 22:00:44', '2026-06-12 00:23:24', NULL, '0fedb8cda245d2ac1ece80f565e53049', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDGPXQN31NNRPJ00PFRAG4', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split lib/app.dart (1187 LOC, five concerns)', 'lib/app.dart is 1187 LOC mixing five confirmed concerns. Split plan (verified against the file 2026-06-12):
Inventory: ClideApp (14-29), _AppRoot (31-45), _RootShell/_RootShellState keyboard+intent routing (47-229), RootLayout (231-350), hat bar family _HatBar/_LeftHatContent/_RightHatContent/_WinBtn (352-439), project switcher family _ProjectSwitcherButton/_ProjectSwitcherDropdown/_RecentProjectRow/_ActionRow (441-672), SlotHost/_SlotHostState/_SlotBody/_SidebarSlot/_WorkspaceSlot/_RevealedTab/_ContextSlot (674-1030), _BottomRail (1032-1078), StatusbarCollapseToggle (1093-1126), StatusbarHost (1128-1170), _EditorDragHandle (924-1010), _WelcomeOverlay (1172-1187).
Target layout:
- app.dart keeps ClideApp + _AppRoot and RE-EXPORTS the public symbols so tests keep importing package:clide/app.dart.
- lib/widgets/root_shell.dart: _RootShell/_RootShellState (keyboard routing; depends on ModifierTapTracker, MenuBarController).
- lib/builtin/hat/hat_bar.dart: _HatBar, _LeftHatContent, _RightHatContent, _WinBtn, hatHeight.
- lib/builtin/hat/project_switcher.dart: the switcher family (~230 LOC).
- lib/widgets/slot_host.dart: SlotHost + slot bodies (NOTE: _RevealedTab references _SlotBody._resolveTitle — keep them together or extract the helper).
- lib/widgets/layout_status.dart: RootLayout internals, StatusbarHost, StatusbarCollapseToggle, _BottomRail, _EditorDragHandle(+Intent), _WelcomeOverlay.
Order: mechanical first (hat bar, switcher rows, welcome overlay), then root shell, then slot host (tangled: _SlotHostState registers focus scopes via ClideKernel.of in didChangeDependencies), then layout/status.
Tests importing app.dart: test/app_test.dart (RootLayout, StatusbarHost, ClideApp), test/app_statusbar_test.dart (StatusbarHost), test/app_collapse_toggle_test.dart (StatusbarCollapseToggle) — re-exports keep them unchanged.
CORRECTION to fable-ous.md: ColumnHat is NOT duplicated line-for-line in app.dart (verified); ColumnHat lives only in lib/widgets/src/clide_column_hat.dart. The rat-sweep ticket T-385 was annotated accordingly — verify whether ColumnHat is dead before deleting.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 22:03:21', '2026-06-12 00:23:56', NULL, '06f8ead95c7934a07b4b74813f74f001', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);