44 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.8 138872e990 release v2.3.1
Patch release: ticket/decision sidebars load on first open (T-352), plus
the KWin frameless-chrome map fix (T-351) and the transient pql-failure
retry (T-350) that landed since 2.3.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:40:09 +02:00
jpmschweitzerandClaude Opus 4.8 a0501b4a0a pql: refetch ticket + decision sidebars when the workspace opens (T-352)
On a desktop launch the daemon's PqlClient boots with workDir set to the
launch CWD (e.g. HOME), not the repo — swapIpcServer only rewires it once
the project opens. The tickets and decisions panes fire their first pql
fetch before that swap, so pql runs in the wrong directory against a
stale/global pql.db and the pane errors (observed:
"ticket_deps.blocker_record_id missing — pql.db is from an earlier
schema"). A manual refresh worked because by then the workspace was open.

This is a wrong-workDir timing issue, not db-busy, so the T-350 retry
doesn't catch it. Both panes now re-fetch on ProjectOpened, which fires
after the IPC server swaps to the project workRoot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:38:44 +02:00
jpmschweitzerandClaude Opus 4.8 7cea13c5c0 linux: request no-decorations on map, not just realize (T-351)
On KDE Plasma 6 / KWin 6 the frameless chrome still showed the native
title bar even with the decoration code compiled in. The KDE
server-decoration request ran on the GtkWidget "realize" signal, but
GTK's Wayland backend only creates the wl_surface on map — so at realize
gdk_wayland_window_get_wl_surface() was null and the request bailed,
leaving KWin (which defaults to server-side decorations on Wayland) to
draw its title bar.

Also connect the handler to "map", where the surface is live. The realize
pass still does the X11 gdk_window_set_decorations hint and bails harmlessly
on the Wayland part, so no duplicate decoration object is created.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:24:50 +02:00
jpmschweitzerandClaude Opus 4.8 9a8175903b pql: retry transient db-busy so sidebar panes don't stick (T-350)
The pql-backed sidebar panes fetch once on first build. If that fetch
fired too early — the planning DB still settling at startup, or a db-busy
SQLite lock under concurrent pql writes (pql exits 69) — the pane showed
"pql … failed" and stayed there until a manual refresh re-fired it.

Retry transient failures at the single chokepoint, PqlClient._run: on a
busy/locked signal (exit 69, or stderr mentioning database is locked /
busy) retry a few times with short backoff before throwing. Genuine
errors aren't busy, so they still surface immediately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:20:36 +02:00
jpmschweitzerandClaude Opus 4.8 b648523a1a release v2.3.0
Cut the accumulated Unreleased entries under a dated 2.3.0 heading and
bump pubspec + licenses self.version. Minor bump: the batch adds features
(file references, task dock, deny-simplify, ticket pick-up, type-filter
chips, VS Code/JetBrains keymaps) alongside the fixes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 19:51:35 +02:00
jpmschweitzerandClaude Opus 4.8 16dc628fee linux: require wayland-client so frameless chrome can't be dropped (T-349)
The frameless custom chrome (D-057) is gated on HAS_WAYLAND_CLIENT, which
was only defined when CMake's non-required wayland-client check happened
to find it. A build host/container without the Wayland client dev headers
(plausible on Bazzite/immutable distros that build in a distrobox) silently
compiled the decoration-suppression out, so the rebuilt app shipped the
compositor's native title bar (double title bar on KDE Plasma Wayland).

Make wayland-client a hard requirement: fail the configure with an
actionable message (Fedora wayland-devel / Debian libwayland-dev) rather
than drop a core feature. Fix the stale "xdg-decoration" comment — the
code uses the KDE server-decoration protocol.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 19:46:25 +02:00
jpmschweitzerandClaude Opus 4.8 b7e0835b74 pre-push: skip the test suite unless lib/ or pubspec changed (T-348)
The hook ran the full ~2min push-check on every push, even one touching
only docs, changelog, pql data, tests, or assets. Diff the pushed range
(from the hook's stdin) and run the full gate only when lib/ (app/runtime
source) or pubspec.* (deps/version) changed; otherwise run just the
instant decisions + changelog gates.

Other changes ride along with a lib change in practice, the full suite
stays available via `make push-check`, and the release CI runs it forced
on a tagged version. A state we can't classify (unfetched remote sha, new
branch) falls back to the full gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 19:22:12 +02:00
jpmschweitzerandClaude Opus 4.8 b0f2580150 persist T-345..T-347 ticket id mappings
The ticket_idmap export was uncommitted working-tree churn that a rebase
DB-rebuild dropped, so pql could not resolve T-347 by id. Re-export the
mappings from the rebuilt DB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 19:22:12 +02:00
jpmschweitzerandClaude Opus 4.8 36f4561ca1 expand PATH on Linux so desktop-launched clide finds pql (T-347)
A desktop launcher gives the app a minimal PATH (e.g. /usr/bin:/bin) with
no ~/.local/bin, where pql installs — so _findOnPath('pql') returned null,
clide spawned the literal 'pql', and Process.start failed with ENOENT;
the pql pane errored. The PATH re-expansion that re-adds ~/.local/bin +
/usr/local/bin ran on macOS only; Linux GUI launches hit the same wall.

Extend it to Linux (homebrew dirs stay macOS-only). Extract the logic
into a pure expandToolPath() so the platform gating is unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 19:22:12 +02:00
Jeroen SchweitzerandClaude 5bbbc72dab test-core: use dart --timeout, drop GNU timeout/setsid wrapper
The external `timeout`/`setsid` wrapper is GNU coreutils and absent on
macOS. When `timeout` wasn't found, run_pass captured the negated
pipeline status (0) into rc and `exit $rc` exited 0 — so the entire core
suite was silently skipped at every push on macOS, while push-check went
green. Switch to dart test's built-in --timeout, the same portable hang
guard ci/test.sh already uses; the core dart passes previously had no
per-test timeout at all. Loses the process-group SIGKILL backstop, which
only fired for a wedged PTY child that ignored the in-Dart timeout.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-10 19:04:26 +02:00
Jeroen SchweitzerandClaude 96f9cdedf4 regenerate macOS goldens for card and collapser suites
macOS had only 3 of the 8 platform goldens linux carries; the
conversation-card, collapser, and tool-collapser suites never had a
macOS baseline, and clide_tab_bar was stale after recent card/font
changes. Regenerated against current rendering so the suite passes on
macOS with full linux parity.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-10 18:46:01 +02:00
jpmschweitzerandClaude Opus 4.8 b441b9d3dc make the coverage harness concurrency-safe (T-345)
The two coverage passes shared fixed paths (coverage/lcov.info →
lcov.parallel.info → merge → rm -f), so a concurrent flutter test
--coverage — a second push gate, or a `make test` during a push — raced
and deleted this run's intermediate mid-merge, crashing merge_lcov with
FileNotFoundError.

Each pass now writes to a per-run mktemp dir via --coverage-path; the
merge runs from there and only the final result lands in coverage/lcov.info
via an atomic rename within coverage/. No shared intermediates, so
concurrent runs can't corrupt each other.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:35:02 +02:00
jpmschweitzer 034a9d289e ticket update 2026-06-10 18:17:13 +02:00
jpmschweitzerandClaude Opus 4.8 fb0179d493 align card font sizes across the conversation stream (T-344)
ClideCollapserCard and ConversationCard sat adjacent but used different
tokens for the same roles, so labels/summaries rendered 1-2px apart.
Standardise both on label = clideFontCaption (14), collapsed summary =
clideFontMeta (13): bump ConversationCard's label up from clideFontSmall,
bring the collapser's summary down from clideFontCaption. Goldens
regenerated for the affected card images.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:15:56 +02:00
jpmschweitzerandClaude Opus 4.8 75cf14f1cb tickets panel: per-type filter chips (T-343)
A row of toggle chips below the filter box — one per pql ticket type,
ordered large→small (Initiative, Epic, Story, Task, Bug), each with its
TicketTypeColors dot + border. Single-click toggles a type; double-click
isolates it (chart-legend solo, fully reversible); disabling the last
enabled type snaps all back on so the list is never blank. ANDed with the
text filter; all on by default, nothing persisted.

One GestureDetector owns both onTap + onDoubleTap so Flutter disambiguates
single vs double. Wireframe updated + approved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:13:04 +02:00
jpmschweitzerandClaude Opus 4.8 91b7dbef4d tickets: file T-343 — ticket-panel type filter chips
Bug / Ticket / Epic / Initiative toggle chips at the top of the tickets
panel, all on by default. Single-click toggles a type; double-click solos
it (chart-legend pattern), double-click again restores all; last-off
resets to all-on. Filed under the UI epic T-276 with a Frame0 wireframe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:54:40 +02:00
jpmschweitzerandClaude Opus 4.8 0a6d5a5ef8 format conversation_view after the resolver extraction
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:52:30 +02:00
jpmschweitzerandClaude Opus 4.8 9b02a976ec extract pick-up + file-resolver helpers for coverage
The T-339 pick-up test was the only importer of the ~450-line claude
extension.dart, pulling its (mostly UI-wiring, untestable) lines into
the coverage denominator and dropping the suite below the 95% floor.

Move applyTicketPickUp into its own ticket_pick_up.dart and the T-300
path resolver into a pure resolveWorkspaceFilePath() — both small, fully
covered, and imported by the tests instead of the whole extension. No
behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:51:47 +02:00
jpmschweitzerandClaude Opus 4.8 6baa6ffdb1 format the T-300 file-ref test
dart format was missed on this file in the T-300 commit; the pre-push
gate's --set-exit-if-changed caught it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:41:20 +02:00
jpmschweitzerandClaude Opus 4.8 62f9975b06 docs: correct stale architecture references
Found by a docs/ staleness audit:
- architecture.md: Claude no longer runs under tmux — it's driven over
  the stream-json control protocol with --resume (D-75/D-77/D-78); and
  the IPC socket server is implemented, not "currently unimplemented".
- testing/README.md + claude-ui-workflow.md: drop the dissolved app/
  two-package paths (D-56) — tests live at test/ and the web build at
  build/web/.
- design/multitab-pane.md: the Claude pane spawns a stream-json session,
  not a tmux one; ClaudeSessionRef carries the session id.

Frozen historical snapshots (initial-plan.md, the HISTORICAL pty docs,
dated spikes/audits) left as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:39:31 +02:00
jpmschweitzerandClaude Opus 4.8 4a9f57b14a tickets: expand T-342 — route nested runs to the right per-agent card
Scope clarification: each per-agent card must pull that agent's full
nested run (prompt + prose/thinking/tool cards/results), correctly
attributed under parallel fan-out. Flags the resolveOwner nearest-lastAgent
fallback (conversation_view.dart:228) as a mis-routing hazard for
concurrent agents. Also pins that existing grouping is preserved: non-agent
foldables still cluster into the Activity card, intra-agent folding
(T-263/T-264/T-338) is reused not rebuilt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:29:25 +02:00
jpmschweitzerandClaude Opus 4.8 bc7ee3b88c tickets: file T-342 — per-subagent activity cards
Investigated whether spawned subagents get their own collapsing card.
They don't: groupConversation/_isFoldable (activity_cluster.dart) treats
Task/Agent tool-uses like any non-diff tool, so consecutive spawns merge
into one "Activity / N steps" cluster. T-263/T-264/T-338 only shaped what
renders INSIDE one agent card. Filed T-342 (under T-276) for the
complement: break distinct Agent spawns out into their own labelled cards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:27:35 +02:00
jpmschweitzerandClaude Opus 4.8 3134a0ac52 tickets: backlog relevance sweep (sub-T-100)
Swept the 19 backlog tickets with ids below T-100 for relevance against
the current codebase + governance.

Cancelled (obsolete):
- T-26 web-tree-sitter — contradicts the desktop-first guardrail
- T-60 workspace trust prompt — premature; third-party ext loading unshipped
- T-40 PRs tab — unscoped, no extension, data path undecided

Annotated (stale text / mixed state, work still valid):
- T-25, T-27 — app/ path prefix stale post-D-56
- T-8 — mixed Tier-6 completion (theme-picker done, settings-ui stub)
- T-55 — split: tmux persistence done, OS tray still a stub
- T-41 — likely superseded by canvas epic (T-317/D-91)
- T-67 — reframe from one-time audit to coverage ratchet
- T-81 — T-79/T-80 done, #21 obsolete, ~7 items remain

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:18:11 +02:00
jpmschweitzerandClaude Opus 4.8 66d3d024ca log the failing command on a dispatch error (T-80)
The catch-all "dispatch threw" log omitted the request command, so a
handler exception couldn't be correlated to what caused it (PTY/IPC
audit item #26). Track the resolved cmd across the request handler and
include it in the error log.

The audit's other two items were already satisfied: errno/signal magic
numbers are centralized in PosixErrno + ffi/libc.dart, and the IPC
server already logs through the kernel Logger (no stderr.writeln left).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:59:09 +02:00
jpmschweitzerandClaude Opus 4.8 a7349aacf1 document that the pre-commit hook auto-stages .pql/changelog
Tell the git-commit skill and CLAUDE.md not to hand-stage .pql/changelog
— the pre-commit hook exports and stages it on every commit. The only
caveat: a ticket-only turn must make at least one commit to fire the hook.
Already documented in the pql skill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:52:16 +02:00
jpmschweitzerandClaude Opus 4.8 8fef818d97 tickets: flush pql changelog export (T-57 notes)
Persist the T-57 note append that a prior commit's stale export missed;
also carries a re-serialized row for an existing backlog ticket.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:48:52 +02:00
jpmschweitzerandClaude Opus 4.8 848f1a4dab tickets: cancel T-45 (obsolete), add notes to T-57
- T-45 cancelled: tmux detect/install gate is obsolete — D-77/D-78 moved
  the Claude pane off tmux to the stream-json control protocol + --resume.
- T-57: noted it applies to both the left sidebar and right context-bar
  rails, and that the left-most item opens by default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:45:46 +02:00
jpmschweitzerandClaude Opus 4.8 30df42c789 fix licenses.yaml drift vs pubspec
- test: 1.30.0 → 1.31.0 (matches pubspec dev_dependencies)
- tree-sitter purpose: stale app/native/ path → native/linux-x64/

Surfaced by the T-25 audit and the 2026-05-14 security review. The
"phantom lints entry" that review also flagged is already gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:44:48 +02:00
jpmschweitzerandClaude Opus 4.8 3a4b758dcb document tooling discipline: make targets, shell hygiene
Add a "Tooling discipline" note to CLAUDE.md — run gates via the make
targets (e.g. make changelog-gate), not the ci/ scripts they wrap; the
Bash working dir is the repo root (no cd / git -C); one command per
invocation. Point the git-commit skill at make changelog-gate for the
60-word cap instead of naming the raw script.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:35:32 +02:00
jpmschweitzerandClaude Opus 4.8 3b3dd71f4a trim Unreleased changelog bullets under the 60-word gate (T-311/T-338/T-339)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:34:01 +02:00
jpmschweitzerandClaude Opus 4.8 3b23da052e fold the Deny & simplify denial instead of a red error (T-340)
A user-initiated denial (Deny & simplify) came back as an isError
tool_result and rendered as a prominent expanded-red "Bash · error"
block — pure noise, since the user chose it. It now folds to a muted,
collapsed "denied" card.

Built as a reusable filter rather than string-matching the note: DenyTool
carries a `quiet` flag, the session collects quiet denials' tool_use_ids,
and ConversationView renders any error whose id is in that set folded +
muted. Genuine tool failures (ids not in the set) keep the expanded-red
treatment. Adding future "expected error" cases is just adding ids.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:29:30 +02:00
jpmschweitzerandClaude Opus 4.8 a948bc8a1f pick-up sets the ticket to in_progress on accept (T-339)
When a ticket is handed to a live Claude pane (T-327), advance it to
in_progress on the receiving side of the bus — gated on acceptance, so a
pick-up with no live session stays a quiet no-op and never mutates state.
Only a not-yet-started ticket (backlog/ready) transitions, so re-picking
up a review/done ticket doesn't drag it backwards. On success it publishes
(builtin.tickets, changed) so the sidebar refreshes.

The handler logic moves into a testable applyTicketPickUp() seam; the
sidebar button now carries the current status in the pick-up payload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:18:16 +02:00
jpmschweitzerandClaude Opus 4.8 dea3e70e49 fold sub-agent prompts via parent_tool_use_id (T-338)
In live stream-json sessions a sub-agent's spawning prompt is tagged
with parent_tool_use_id, not the transcript JSONL's isSidechain +
parentUuid. The parser ignored that field, so the prompt parsed as a
main-thread user turn and rendered as a blue "you" card above the
Activity Agent card instead of folding into it.

Carry parent_tool_use_id onto ConversationItem; its presence now marks
the item as a sidechain message. The sidechain fold resolves ownership
directly by tool-use id (no transcript-only uuid chain to walk), so the
prompt folds into its Agent card and the run nests under it as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:03:50 +02:00
jpmschweitzerandClaude Opus 4.8 026e331146 clickable file references in the Claude conversation (T-300)
Workspace file paths mentioned by Claude now linkify and open in the
editor: bare (lib/app.dart), with a line (lib/app.dart:42), backticked,
or as markdown links. Only paths that exist in the repo linkify — the
resolver gates on existence so prose (version numbers, "e.g.") stays
literal. Clicking maps to the editor.open verb, jumping to the line when
a :line suffix is present (D-6 parity).

ClideMarkdown gains resolveFileRef + onOpenFile hooks; conversation_view
resolves against the open project root + existsSync and dispatches over
IPC. Detection covers running prose, whole-content code spans, and link
hrefs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:55:53 +02:00
jpmschweitzerandClaude Opus 4.8 984bf0cfaf file T-338: agent prompt renders as a 'you' card instead of folding into the Activity Agent card
Bug under the UI tracker epic (T-276). The sub-agent prompt leaks to the
top level as a blue 'you' UserMessage instead of folding into the Activity
Agent card (T-263 behaviour), because the fold keys off envelope
isSidechain/parentUuid and the spawning prompt's envelope arrives without
them resolving to the Agent tool_use.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:48:43 +02:00
jpmschweitzerandClaude Opus 4.8 c6fa384972 file SSH-remote workspaces epic (T-329) with phased stories
Plan an epic to run clide locally against a repo on a remote machine over
SSH: execution remote (git, pql, claude, terminal, file I/O), UI + clipboard
local, bridged by SSH. Resolves Q-23.

Epic T-329 with stories T-330..T-336: Phase-0 governance spike (gates the
epic; decides the footprint model — no-install ssh-exec vs auto-pushed
agent — and resolves Q-23), model-independent backbone (transport seam,
ssh:// open scheme, Claude stdio-control remoting, image-paste bridge, UI),
and an execution-layer fork placeholder that expands after the spike.
Dependency chain wired via ticket blocks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:29:00 +02:00
jpmschweitzerandClaude Opus 4.8 ec88944896 Deny & simplify note: tell Claude not to narrate the retry (T-328)
Add a clause to the preformatted deny-simplify note so Claude proceeds
silently with the simpler version instead of narrating the change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:19:15 +02:00
jpmschweitzerandClaude Opus 4.8 597b2f7622 dismiss the lightbox on a tap outside the image (T-309)
The image fills the 94% viewer with a transparent letterbox, so taps on
the dimmed canvas beside the image hit the viewer and did nothing — only
the 6% margin dismissed. Add onTapUp that dismisses when the tap lands
outside the actual painted-image rect (computed from the RenderImage +
applyBoxFit, transformed for any zoom/pan). Double-tap reset, scroll
zoom, pan, Esc, and the close button are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:18:00 +02:00
jpmschweitzerandClaude Opus 4.8 9607124c2b fix ClideStatusIndicator duplicate-key crash on rapid status flips (T-326)
AnimatedSwitcher cross-fades exiting + entering glyphs for 200ms; with a
fixed per-status ValueKey, flipping a status back (running -> success ->
running within the fade, e.g. two bound Claude panes) left two children
keyed 'running' in the Stack -> "Duplicate keys found" + a cascade of
follow-on framework errors. Make the indicator stateful and fold a
per-change sequence counter into the key, so each appearance is unique and
a same-status rebuild still doesn't re-animate. Regression test flips
status mid-cross-fade with real animations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:00:58 +02:00
jpmschweitzerandClaude Opus 4.8 3097d5f63b hand a ticket to Claude from the sidebar (T-327)
Hovering a ticket card reveals a person-simple-run icon; clicking it
fetches the full ticket (pql.tickets.show withContext), builds a "pick
this up and start" prompt, and publishes ('builtin.tickets','pick-up',
{id,prompt}) on the message bus. The Claude builtin subscribes and
injects it into the active session (primary, else first visible) as a
user turn — a quiet no-op when no session is live. Sidebar stays
decoupled from the orchestrator (bus-only). Prompt-builder test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:52:27 +02:00
jpmschweitzerandClaude Opus 4.8 aa6f112164 bump run-status spinner to 26 (T-304)
Settle the activity-card run-status size at 26 (clideIconHero) — a touch
larger than the 24 trial, which reads better in review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:40:52 +02:00
jpmschweitzerandClaude Opus 4.8 4f632d3cdd dock Claude's task list above the composer (T-308)
Claude's TodoWrite checklist was invisible. Add a TaskItem/TaskStatus
model + a latest-wins parser (taskListFrom) that reads the most recent
TodoWrite tool call (it replaces the whole list each time), and a compact
display-only ClaudeTaskDock pinned between the conversation and the
composer: collapsed to "N tasks · M done" + the current in-progress item,
expandable to the full checklist with per-item status glyphs + a11y
labels. Hidden when there are no tasks. Parser + widget tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:31:35 +02:00
jpmschweitzerandClaude Opus 4.8 2c8e9f2821 add 'Deny & simplify' permission option (T-311)
A fourth button on the permission card that denies the action with a
preformatted note: it's too complex for the permission system, retry in a
simpler/more granular form, and explicitly do NOT add a memory or change
permission settings (so Claude reformulates instead of fiddling with the
permission surface). A typed note is appended rather than discarded.
Addressable by number key (4 with remember, else 3); tooltip explains it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:14:00 +02:00
jpmschweitzerandClaude Opus 4.8 01445c4fae enlarge the activity-card run-status spinner (T-304)
At size 12 the rotating logo-mark spinner on in-progress collapser cards
read as a static speck. Add a clideIconHero (24) control-geometry token
and use it for ClideStatusIndicator on the collapser — the spinner now
reads as motion at a glance; the success check / error cross share the
size so the card doesn't jump when the run settles. Only call site is the
collapser, so other status surfaces are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:05:17 +02:00
71 changed files with 4090 additions and 181 deletions
+5 -1
View File
@@ -55,7 +55,7 @@ Entries should be short imperative phrases that describe user-facing impact —
### Be concise — this is the rule, not a suggestion
CHANGELOG entries must be **one or two short sentences**. Hard cap: **60 words per bullet** (enforced by `ci/changelog_gate.sh`). Aim for 30 or under; if you can't say it in one line wrapped at ~75 columns, you're writing the wrong document.
CHANGELOG entries must be **one or two short sentences**. Hard cap: **60 words per bullet**, enforced by the pre-push gate — verify before committing with `make changelog-gate` (run the `make` target, not the script it wraps). Aim for 30 or under; if you can't say it in one line wrapped at ~75 columns, you're writing the wrong document.
The CHANGELOG is read by humans scanning for what changed between two versions. It is **not** the place for the rationale, the probe results, the implementation detail, the behavior-change deep dive, or the "see also" cross-references. Those belong in:
@@ -134,6 +134,10 @@ Never pass multi-line messages via `-m "line1\nline2"` or multiple `-m` flags
- SQLite index files (`*.sqlite`, `*.sqlite-wal`, `*.sqlite-shm`, `*.db`) — caches generated against local repos; must never land here. Gitignored defensively.
- Coverage / test output (`*.out`, `coverage.*`, `*.test`) — gitignored.
## Don't hand-manage `.pql/changelog`
The pre-commit hook exports the pql ticket DB and **auto-stages `.pql/changelog/` on every commit**. Don't `git add .pql/changelog` yourself and don't write a dedicated "flush the export" commit — just make your normal commit and the hook sweeps the ticket state in. The only thing to remember: a turn that files/changes a ticket but makes **zero commits** never fires the hook, so the change won't persist (and a later branch switch can drop it). The fix is simply to make a commit — you don't need to touch `.pql/changelog`.
## Safety reminders (reinforced from the global Claude Code protocol)
- **Never** `--no-verify`. If a pre-commit hook fails, fix the underlying issue and create a new commit.
+45
View File
@@ -4,9 +4,54 @@
# Install: `make hooks` (points git core.hooksPath at .githooks/).
# Bypass: never. If this runs slowly, fix the slow test; don't reach
# for --no-verify (git-commit skill forbids it).
#
# Fast path (T-348): run the full ~2min test suite only when the push touches
# lib/ (app + runtime Dart source) or pubspec.* (deps / version). test/,
# assets/, docs, and tooling changes ride along with a lib change in practice,
# and an otherwise-skipped push is covered by the next one that does touch lib.
# The full suite is always available via `make push-check`, and the release CI
# runs it forced on a tagged version. So a lib/pubspec-free push runs just the
# instant decisions + changelog gates. A state we can't classify (unfetched
# remote, new branch) runs the full gate.
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
z40=0000000000000000000000000000000000000000
# Collect every file changed across the commits being pushed. git feeds the
# hook one line per ref on stdin: <local-ref> <local-sha> <remote-ref> <remote-sha>.
changed=""
force_full=0
while read -r _local_ref local_sha _remote_ref remote_sha; do
[[ "$local_sha" == "$z40" ]] && continue # branch deletion — nothing to test
if [[ "$remote_sha" == "$z40" ]]; then
# New remote branch: diff from its merge-base with main, else play it safe.
base="$(git merge-base "$local_sha" origin/main 2>/dev/null || true)"
else
base="$remote_sha"
fi
# If we can't resolve a base locally (e.g. the remote advanced and we haven't
# fetched its objects), we can't classify the diff — run the full gate.
if [[ -z "$base" ]] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then
force_full=1
break
fi
changed+=$'\n'"$(git diff --name-only "$base" "$local_sha")"
done
# Run the full gate when lib/ (app + runtime source) or pubspec.* (deps /
# version) is touched, or when we couldn't classify above.
needs_gate=1
if [[ "$force_full" -eq 0 ]]; then
trigger_files="$(printf '%s\n' "$changed" | grep -E '^(lib/|pubspec\.)' || true)"
[[ -z "$trigger_files" ]] && needs_gate=0
fi
if [[ "$needs_gate" -eq 0 ]]; then
echo "==> pre-push: no lib/ or pubspec change — decisions + changelog gates, skipping tests"
make decisions-validate changelog-gate
else
echo "==> pre-push: make push-check"
make push-check
fi
+11
View File
@@ -15,3 +15,14 @@ INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updat
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ERREMEEF26KKHGNZBWW64', '06FB2ETJQP0CT6X7W3CWZ6NS9G', '2026-06-10 11:12:20', '2026-06-10 11:12:20', NULL, 'cb20764a77d2a15290ed61a186542095', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ERREMEEF26KKHGNZBWW64', '06FB2EV29HSK6EJ5VF50R87VC4', '2026-06-10 11:12:25', '2026-06-10 11:12:25', NULL, 'dd9434426790edcaa556221f74c431ba', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ERREMEEF26KKHGNZBWW64', '06FB2G1WD1839Z90AQ5C0BHNV4', '2026-06-10 11:17:25', '2026-06-10 11:17:25', NULL, '5535e3d16bee2a2cdfcbeb84ea3fca99', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5TWC00GW0P3X02HZW', '06FB2TY91VHK7TPKPMZ11EG3TM', '2026-06-10 12:04:53', '2026-06-10 12:04:53', NULL, 'ed3717d8f6467c0a77236eda670efce5', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5TWC00GW0P3X02HZW', '06FB2TY91VHK7TPKPMZ11EG3TM', '2026-06-10 12:04:53', '2026-06-10 12:05:01', '2026-06-10 12:05:01', '137cd047c9eaec01cac955b49fedd839', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DJZDDZ00BSA04B660RS7M', '06FB3DKQQJ583944DG8561VQ3G', '2026-06-10 13:27:04', '2026-06-10 13:27:04', NULL, '73383d1011fbad641395f27271b141e6', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DKQQJ583944DG8561VQ3G', '06FB3DMF20SYFDT6WX2RFBQXKW', '2026-06-10 13:27:04', '2026-06-10 13:27:04', NULL, '45533aa2b124e6547a3804294fcf3aaf', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DJZDDZ00BSA04B660RS7M', '06FB3DQEMTDHF8SV27AKAB8JHW', '2026-06-10 13:27:05', '2026-06-10 13:27:05', NULL, '0eb2faf91e36a4d37a8a3aa97a8edb71', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DKQQJ583944DG8561VQ3G', '06FB3DQEMTDHF8SV27AKAB8JHW', '2026-06-10 13:27:05', '2026-06-10 13:27:05', NULL, 'b329ff8925097a977f528e7e114d6055', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', '06FB3DN94MBCTYJW17ZCYVSXE0', '2026-06-10 13:27:08', '2026-06-10 13:27:08', NULL, 'f494f6efad2377e6fc6d0bbf7ae35f07', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', '06FB3DN94MBCTYJW17ZCYVSXE0', '2026-06-10 13:27:09', '2026-06-10 13:27:09', NULL, 'e5d02fc3a99106d29a9ce6af0626f68e', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', '06FB3DNQZKV20F7YG5PJH8V8SM', '2026-06-10 13:27:09', '2026-06-10 13:27:09', NULL, '1d5185ae9c9676bd70d0e02e3a5e79a1', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', '06FB3DNQZKV20F7YG5PJH8V8SM', '2026-06-10 13:27:10', '2026-06-10 13:27:10', NULL, '90dca94aa700c143290b2b1afaca09ed', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', '06FB3DP48FS33CQGRDF7EB9GT0', '2026-06-10 13:27:10', '2026-06-10 13:27:10', NULL, '9b9081edc77f0e9a4b689bbc191771db', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
+389
View File
@@ -3125,3 +3125,392 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1Q1Y3CYJHD7W5F68VDB6T4', 'status', 'backlog', 'ready', NULL, '2026-06-10 11:33:26', '2026-06-10 11:33:26', '2026-06-10 11:33:26', NULL, 'f280d9b2793130900d5e470c4cb6c088', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4KS233FGZE9H7ABWR', 'status', 'backlog', 'ready', NULL, '2026-06-10 11:33:35', '2026-06-10 11:33:35', '2026-06-10 11:33:35', NULL, 'bb110c716ec26f37ed4ece36b582d4d5', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2J2HWD66QAFDDRRWS5NM48', 'status', 'ready', 'done', NULL, '2026-06-10 11:36:35', '2026-06-10 11:36:35', '2026-06-10 11:36:35', NULL, '376bbab713452158f399f02429870546', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4KS233FGZE9H7ABWR', 'description', '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 runningClideSpinner, successcheck, errorcross 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.', '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 runningClideSpinner, successcheck, errorcross 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.', NULL, '2026-06-10 11:55:15', '2026-06-10 11:55:15', '2026-06-10 11:55:15', NULL, '3be7c3ff633afbc67c91dd3f97c27e6f', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4KS233FGZE9H7ABWR', 'status', 'ready', 'done', NULL, '2026-06-10 12:05:17', '2026-06-10 12:05:17', '2026-06-10 12:05:17', NULL, 'a19ce81190caca50c2628a98835d41a3', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2TY91VHK7TPKPMZ11EG3TM', 'parent_id', NULL, 'T-276', NULL, '2026-06-10 12:05:21', '2026-06-10 12:05:21', '2026-06-10 12:05:21', NULL, 'fefee4b227754f03c985841fb869a346', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2TY91VHK7TPKPMZ11EG3TM', 'description', NULL, '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.', NULL, '2026-06-10 12:05:39', '2026-06-10 12:05:39', '2026-06-10 12:05:39', NULL, '540deffcc7de142a4732a92b5f1dc5be', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2TY91VHK7TPKPMZ11EG3TM', 'priority', 'medium', 'high', NULL, '2026-06-10 12:05:39', '2026-06-10 12:05:39', '2026-06-10 12:05:39', NULL, 'e845355200ef8aaabc986d577724d39e', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2TY91VHK7TPKPMZ11EG3TM', 'status', 'backlog', 'ready', NULL, '2026-06-10 12:07:39', '2026-06-10 12:07:39', '2026-06-10 12:07:39', NULL, 'b013a394e376245d8248d776021e0314', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5TWC00GW0P3X02HZW', 'status', 'backlog', 'ready', NULL, '2026-06-10 12:08:14', '2026-06-10 12:08:14', '2026-06-10 12:08:14', NULL, 'f56fda6382cbec3561ef644954c04f43', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5TWC00GW0P3X02HZW', 'status', 'ready', 'backlog', NULL, '2026-06-10 12:08:18', '2026-06-10 12:08:18', '2026-06-10 12:08:18', NULL, '12eee2948e8f79732b40384a40429e14', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2W4G9K8ZF782W7H2TM5XA8', 'status', 'backlog', 'ready', NULL, '2026-06-10 12:12:30', '2026-06-10 12:12:30', '2026-06-10 12:12:30', NULL, '6b9aaf9bd7ec3ad4ad3b95d1468ff9cf', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1XDWKQ594ET4GDYEFK5ZJ4', 'status', 'ready', 'done', NULL, '2026-06-10 12:14:00', '2026-06-10 12:14:00', '2026-06-10 12:14:00', NULL, 'd90deec4e27b4298c6f7280338599c01', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1Q1Y3CYJHD7W5F68VDB6T4', 'status', 'ready', 'done', NULL, '2026-06-10 12:31:23', '2026-06-10 12:31:23', '2026-06-10 12:31:23', NULL, 'dbac53eef0b8d58dbb5f00a2f77ee52f', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1Q1Y3CYJHD7W5F68VDB6T4', 'status', 'done', 'done', NULL, '2026-06-10 12:32:07', '2026-06-10 12:32:07', '2026-06-10 12:32:07', NULL, 'ea979d683072b321ce6eae3dbbbf6c31', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2W4G9K8ZF782W7H2TM5XA8', 'status', 'ready', 'done', NULL, '2026-06-10 12:51:50', '2026-06-10 12:51:50', '2026-06-10 12:51:50', NULL, 'a8a700b9b92afcb998b5c2e69660e1b2', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1S7613SYF0M9XQT5JNWM40', 'status', 'backlog', 'ready', NULL, '2026-06-10 12:56:38', '2026-06-10 12:56:38', '2026-06-10 12:56:38', NULL, '0c7b9f022143b733461ca7b1b715c435', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB37JZSFZKWPK9PDFYJY2YC0', 'status', 'backlog', 'ready', NULL, '2026-06-10 13:00:19', '2026-06-10 13:00:19', '2026-06-10 13:00:19', NULL, 'a394b4a8b72a7bc99c7fc1a1ac6a2f19', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2TY91VHK7TPKPMZ11EG3TM', 'status', 'ready', 'done', NULL, '2026-06-10 13:00:29', '2026-06-10 13:00:29', '2026-06-10 13:00:29', NULL, '25246286c46b36a114817316658049c7', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1S7613SYF0M9XQT5JNWM40', 'status', 'ready', 'done', NULL, '2026-06-10 13:15:15', '2026-06-10 13:15:15', '2026-06-10 13:15:15', NULL, '9cea01c55347a9c347bd195d322dd004', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB37JZSFZKWPK9PDFYJY2YC0', 'status', 'ready', 'done', NULL, '2026-06-10 13:18:57', '2026-06-10 13:18:57', '2026-06-10 13:18:57', NULL, '02a8ea1b2623be255101f56c077f63ee', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7F2TBJJV1F2KP7XR8', 'status', 'backlog', 'ready', NULL, '2026-06-10 13:24:15', '2026-06-10 13:24:15', '2026-06-10 13:24:15', NULL, '85d0441183cc6f64fb1a956522a60f4a', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7F2TBJJV1F2KP7XR8', 'status', 'ready', 'in_progress', NULL, '2026-06-10 13:41:17', '2026-06-10 13:41:17', '2026-06-10 13:41:17', NULL, '14fa1745f31d356412fe910180183a53', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JAXDKZMS0805MMEYB6820', 'status', 'backlog', 'ready', NULL, '2026-06-10 13:53:28', '2026-06-10 13:53:28', '2026-06-10 13:53:28', NULL, 'addfc7d2a002212e979b3d7237cf37ce', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3KS499THAD899M0NWN7E3R', 'status', 'backlog', 'ready', NULL, '2026-06-10 13:53:55', '2026-06-10 13:53:55', '2026-06-10 13:53:55', NULL, '72773d8d91af176305bd6c1b7d5d65be', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JM0AXK1CTWD720RV1AVZ0', 'status', 'backlog', 'ready', NULL, '2026-06-10 13:54:02', '2026-06-10 13:54:02', '2026-06-10 13:54:02', NULL, '5cb488441033ac5a781d4b7d91dbda6f', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7F2TBJJV1F2KP7XR8', 'status', 'in_progress', 'done', NULL, '2026-06-10 13:55:30', '2026-06-10 13:55:30', '2026-06-10 13:55:30', NULL, 'f618ee8a3a33dabfa822bd0cebae4e03', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JAXDKZMS0805MMEYB6820', 'status', 'ready', 'done', NULL, '2026-06-10 14:03:10', '2026-06-10 14:03:10', '2026-06-10 14:03:10', NULL, '96365bc3ef7dbf505448be2dad16e4b6', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JM0AXK1CTWD720RV1AVZ0', 'status', 'ready', 'done', NULL, '2026-06-10 14:14:33', '2026-06-10 14:14:33', '2026-06-10 14:14:33', NULL, 'd028dbbdbad663a682c5ed4d92dc7cc2', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3KS499THAD899M0NWN7E3R', 'status', 'ready', 'done', NULL, '2026-06-10 14:29:07', '2026-06-10 14:29:07', '2026-06-10 14:29:07', NULL, 'b7a07e0516cc232a959ff1930f338f15', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4QKBQY7FPCNX6N28R', 'description', '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.', '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.', NULL, '2026-06-10 14:41:37', '2026-06-10 14:41:37', '2026-06-10 14:41:37', NULL, '020a1027abc50a0ab63ac839380dc767', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4QKBQY7FPCNX6N28R', 'status', 'backlog', 'cancelled', NULL, '2026-06-10 14:41:47', '2026-06-10 14:41:47', '2026-06-10 14:41:47', NULL, 'c1323729d101d47e5ea20df81e40735a', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4TQEYESK545T8F164', 'description', 'Reorder tabs in the sidebar and context panel icon rails by dragging. Persist order to project settings.', '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.', NULL, '2026-06-10 14:45:17', '2026-06-10 14:45:17', '2026-06-10 14:45:17', NULL, '86f1c477f9408ce87b624799a73b82c0', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM734YZ060Q63H40EYG', 'status', 'backlog', 'ready', NULL, '2026-06-10 14:46:51', '2026-06-10 14:46:51', '2026-06-10 14:46:51', NULL, 'a3785538cc5ea4040c7f88ee615342b4', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM734YZ060Q63H40EYG', 'status', 'ready', 'in_progress', NULL, '2026-06-10 14:53:03', '2026-06-10 14:53:03', '2026-06-10 14:53:03', NULL, '6fe6ac08d39ee01463fc6e629a0b00d7', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM734YZ060Q63H40EYG', 'description', '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.', '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.', NULL, '2026-06-10 14:58:32', '2026-06-10 14:58:32', '2026-06-10 14:58:32', NULL, '067666253769388e306dd99c4f976df9', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM734YZ060Q63H40EYG', 'status', 'in_progress', 'done', NULL, '2026-06-10 14:58:43', '2026-06-10 14:58:43', '2026-06-10 14:58:43', NULL, '192064fbd5628b282512128ddc3d2688', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM42M9RK4399B4F4WSG', 'status', 'backlog', 'ready', NULL, '2026-06-10 15:01:43', '2026-06-10 15:01:43', '2026-06-10 15:01:43', NULL, 'a002c3ce8b3efa35c18d9708ed37c9c3', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM60QRRNEEWG84VWKXC', 'status', 'backlog', 'ready', NULL, '2026-06-10 15:01:52', '2026-06-10 15:01:52', '2026-06-10 15:01:52', NULL, 'dee252ba6fbb07189c947101968e1743', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM42M9RK4399B4F4WSG', 'status', 'ready', 'in_progress', NULL, '2026-06-10 15:01:54', '2026-06-10 15:01:54', '2026-06-10 15:01:54', NULL, '1c3ed11e37579ea9a3a21e85e3ba110a', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM60QRRNEEWG84VWKXC', 'status', 'ready', 'in_progress', NULL, '2026-06-10 15:02:14', '2026-06-10 15:02:14', '2026-06-10 15:02:14', NULL, 'c5844c06118422cb09538405b12fe478', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM42M9RK4399B4F4WSG', 'description', '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).', '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.', NULL, '2026-06-10 15:07:52', '2026-06-10 15:07:52', '2026-06-10 15:07:52', NULL, 'f5caba1a25d32651bbd7503c906633cf', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM60QRRNEEWG84VWKXC', 'description', '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).', '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.', NULL, '2026-06-10 15:07:52', '2026-06-10 15:07:52', '2026-06-10 15:07:52', NULL, '18bc4f20a98f7528f3a9a976e34d9377', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5YG22EV7BFTX5RTPR', 'description', '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.', '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.', NULL, '2026-06-10 15:13:50', '2026-06-10 15:13:50', '2026-06-10 15:13:50', NULL, 'ad42a5b7d9d3cc5c5e0e1ed97b9858aa', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM50X0DX8XTVZEA5GN8', 'description', '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.', '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).', NULL, '2026-06-10 15:13:52', '2026-06-10 15:13:52', '2026-06-10 15:13:52', NULL, 'c4e57efc161c7afbc5e8a3c3d08b8d74', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6RDM2EKZX132GPV7M', 'description', 'Spec lists PRs as a left-panel section (icon rail position 5). No extension exists yet.', '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.', NULL, '2026-06-10 15:13:52', '2026-06-10 15:13:52', '2026-06-10 15:13:52', NULL, '99b1fceb9134d0e6bead70da08a51710', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4W194B2421P2SF83R', 'description', '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.', '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.', NULL, '2026-06-10 15:13:54', '2026-06-10 15:13:54', '2026-06-10 15:13:54', NULL, '3ddd8faeeaabb70a137165ec736e8a07', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM48MDE8ZZ82VWNY994', 'description', '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.', '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.', NULL, '2026-06-10 15:13:56', '2026-06-10 15:13:56', '2026-06-10 15:13:56', NULL, '81d27a7cf777fc0290574d807938ea36', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6T6580D8ABDVTMNZW', 'description', '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.', '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.', NULL, '2026-06-10 15:13:57', '2026-06-10 15:13:57', '2026-06-10 15:13:57', NULL, '15d4ca47570b23a19b16c22e40a8d73e', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM66FTCTWHH9AQTNFKR', 'description', '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.', '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.', NULL, '2026-06-10 15:13:59', '2026-06-10 15:13:59', '2026-06-10 15:13:59', NULL, '8a2142b2ae85644bdbe22627ce3bde77', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM67JSC5RKS6M9182KG', 'description', 'Spec lists images as a right-panel section. No extension exists yet.', '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.', NULL, '2026-06-10 15:14:01', '2026-06-10 15:14:01', '2026-06-10 15:14:01', NULL, 'faf96dd64f003093273f6a331c56bfa8', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7P6Q0DG4RG4CBHFJG', 'description', '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.', '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.', NULL, '2026-06-10 15:14:02', '2026-06-10 15:14:02', '2026-06-10 15:14:02', NULL, 'a83b56d7fee3b9a20bc0d78f6125532a', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM79CNBXJ2S3CFQR7VM', 'description', '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.', '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.', NULL, '2026-06-10 15:14:04', '2026-06-10 15:14:04', '2026-06-10 15:14:04', NULL, '94ea0fc1d09ea5eb98acf8ff624ac984', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5YG22EV7BFTX5RTPR', 'status', 'backlog', 'cancelled', NULL, '2026-06-10 15:14:17', '2026-06-10 15:14:17', '2026-06-10 15:14:17', NULL, 'b459f350a8c1a86865579cde7f7b02b1', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM50X0DX8XTVZEA5GN8', 'status', 'backlog', 'cancelled', NULL, '2026-06-10 15:14:17', '2026-06-10 15:14:17', '2026-06-10 15:14:17', NULL, '351f1eede59364f6bb95653147b98ee2', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6RDM2EKZX132GPV7M', 'status', 'backlog', 'cancelled', NULL, '2026-06-10 15:14:17', '2026-06-10 15:14:17', '2026-06-10 15:14:17', NULL, '992ba3fca9faf99417aaa1a7afa9e6be', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM42M9RK4399B4F4WSG', 'status', 'in_progress', 'done', NULL, '2026-06-10 15:17:59', '2026-06-10 15:17:59', '2026-06-10 15:17:59', NULL, '61000cdb8bb01686c6001f57d16ae717', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM60QRRNEEWG84VWKXC', 'status', 'in_progress', 'done', NULL, '2026-06-10 15:17:59', '2026-06-10 15:17:59', '2026-06-10 15:17:59', NULL, '81990604a08fbedc28075b95538625d6', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB493JEW32CH0H3771TNHF7G', 'description', NULL, '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.', NULL, '2026-06-10 15:27:01', '2026-06-10 15:27:01', '2026-06-10 15:27:01', NULL, '59b702a540ce65892ea2c6fce5d1a045', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB493JEW32CH0H3771TNHF7G', 'description', '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.', '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.', NULL, '2026-06-10 15:28:52', '2026-06-10 15:28:52', '2026-06-10 15:28:52', NULL, '0352b24d88a4bf24ede628880fe9b502', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4FDREHRYRR7B9ER72KCQKC', 'status', 'backlog', 'in_progress', NULL, '2026-06-10 15:54:23', '2026-06-10 15:54:23', '2026-06-10 15:54:23', NULL, '8061a1d449acdb1e467239de0dce5d1d', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4FDREHRYRR7B9ER72KCQKC', 'status', 'in_progress', 'in_progress', NULL, '2026-06-10 16:02:55', '2026-06-10 16:02:55', '2026-06-10 16:02:55', NULL, '9c00a4d5fefdc3cf8f227b040bc78acb', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4FDREHRYRR7B9ER72KCQKC', 'description', '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)', '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 LARGESMALL 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.', NULL, '2026-06-10 16:06:25', '2026-06-10 16:06:25', '2026-06-10 16:06:25', NULL, '26b5b4d5cf2a48fe6aeacd7b83968848', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5TWC00GW0P3X02HZW', 'status', 'backlog', 'ready', NULL, '2026-06-10 16:09:21', '2026-06-10 16:09:21', '2026-06-10 16:09:21', NULL, 'c89b4ddcbdeea95483c53b1b76220842', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5TWC00GW0P3X02HZW', 'status', 'ready', 'in_progress', NULL, '2026-06-10 16:09:26', '2026-06-10 16:09:26', '2026-06-10 16:09:26', NULL, 'c36e6343d44f59ff4af0eea3178b5a94', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5TWC00GW0P3X02HZW', 'status', 'in_progress', 'backlog', NULL, '2026-06-10 16:09:57', '2026-06-10 16:09:57', '2026-06-10 16:09:57', NULL, 'be22c7ff64f106d600b4a5bd70ced1d2', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4J5E6W983P1S7BE0FDPSMM', 'status', 'backlog', 'ready', NULL, '2026-06-10 16:10:20', '2026-06-10 16:10:20', '2026-06-10 16:10:20', NULL, '6f4b3ce9e2167200ebdde0c1511d4a91', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4J5E6W983P1S7BE0FDPSMM', 'status', 'ready', 'in_progress', NULL, '2026-06-10 16:10:22', '2026-06-10 16:10:22', '2026-06-10 16:10:22', NULL, 'cce7ce3f17ae087f753f4d6d072a702e', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4FDREHRYRR7B9ER72KCQKC', 'status', 'in_progress', 'done', NULL, '2026-06-10 16:12:15', '2026-06-10 16:12:15', '2026-06-10 16:12:15', NULL, '4cb2daa831a115888a9456461bf53bdf', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4J5E6W983P1S7BE0FDPSMM', 'status', 'in_progress', 'in_progress', NULL, '2026-06-10 16:13:50', '2026-06-10 16:13:50', '2026-06-10 16:13:50', NULL, '88bf89e802bd16209db6d2c0a9b963c0', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4J5E6W983P1S7BE0FDPSMM', 'status', 'in_progress', 'done', NULL, '2026-06-10 16:15:38', '2026-06-10 16:15:38', '2026-06-10 16:15:38', NULL, '4aa30d9c68463d18f5778cbd20c9da5a', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB493JEW32CH0H3771TNHF7G', 'status', 'backlog', 'ready', NULL, '2026-06-10 16:21:55', '2026-06-10 16:21:55', '2026-06-10 16:21:55', NULL, 'b2f20ad3cb093b7e89342f6b374097ff', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB44SKPKTHFMV6WD28GZYPXM', 'status', 'backlog', 'ready', NULL, '2026-06-10 16:22:24', '2026-06-10 16:22:24', '2026-06-10 16:22:24', NULL, 'b88b0e1ef2e1c3711527388dc8d07cce', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'status', 'backlog', 'ready', NULL, '2026-06-10 16:22:28', '2026-06-10 16:22:28', '2026-06-10 16:22:28', NULL, '4b58326ae217b60be81a07603afee36d', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2T11GCV1EV07DYD5BZENTM', 'status', 'backlog', 'ready', NULL, '2026-06-10 16:22:37', '2026-06-10 16:22:37', '2026-06-10 16:22:37', NULL, 'c62c5ac18a88ba763b5194f89a2a7482', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4RD1DDSYM4J7WYEGTXARB4', 'status', 'backlog', 'done', NULL, '2026-06-10 16:34:51', '2026-06-10 16:34:51', '2026-06-10 16:34:51', NULL, '56cde33409eb796ad4fb420ed3033c70', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4XCM5KBXDDSCWJ37GPYG3R', 'status', 'backlog', 'done', NULL, '2026-06-10 16:57:07', '2026-06-10 16:57:07', '2026-06-10 16:57:07', NULL, '1ce291d54831d3ede687384037c379ff', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB50YE6S6YWNP2ZSFWES9B2W', 'status', 'backlog', 'done', NULL, '2026-06-10 17:13:53', '2026-06-10 17:13:53', '2026-06-10 17:13:53', NULL, '87b069d3f25c91622c9720147d359360', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB58X0TFJ02YTMVPD0D9Q838', 'status', 'backlog', 'done', NULL, '2026-06-10 17:47:01', '2026-06-10 17:47:01', '2026-06-10 17:47:01', NULL, 'ba3d17ec4e668ade82b07d2bb848ab91', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5CW7JPT6BR2RWMNYVCXJ50', 'status', 'backlog', 'done', NULL, '2026-06-10 18:20:28', '2026-06-10 18:20:28', '2026-06-10 18:20:28', NULL, '213109fcd67b4375ebbd3b59c4a1ed04', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'status', 'backlog', 'review', NULL, '2026-06-10 18:24:41', '2026-06-10 18:24:41', '2026-06-10 18:24:41', NULL, 'c5a88f9594c44896a6a3d1a4b2418ed2', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'status', 'review', 'done', NULL, '2026-06-10 18:27:58', '2026-06-10 18:27:58', '2026-06-10 18:27:58', NULL, 'a9c72ab53b69f5ca6bf1fa4dd0ddfa05', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5M14B76B31654D959XM5AC', 'status', 'backlog', 'done', NULL, '2026-06-10 18:38:53', '2026-06-10 18:38:53', '2026-06-10 18:38:53', NULL, 'b94cfe8ba315b3be6775474c681b4e80', 2) ON CONFLICT(hash) DO NOTHING;
+28
View File
@@ -149,3 +149,31 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G1WD1839Z90AQ5C0BHNV4', 'T-322', '2026-06-10 11:17:17', '2026-06-10 11:17:17', NULL, 'f33b1db4b67a0521f02c5c49fcfa8e6f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'T-323', '2026-06-10 11:17:23', '2026-06-10 11:17:23', NULL, '0738a8da475a6d8d49132d1e4e753775', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2J2HWD66QAFDDRRWS5NM48', 'T-324', '2026-06-10 11:26:07', '2026-06-10 11:26:07', NULL, 'd8aa9ae4522d97b70331c42b8ab7a365', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2T11GCV1EV07DYD5BZENTM', 'T-325', '2026-06-10 12:00:52', '2026-06-10 12:00:52', NULL, '47b8f31d9337c5bbc6476d62c7f6ed4d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2TY91VHK7TPKPMZ11EG3TM', 'T-326', '2026-06-10 12:04:51', '2026-06-10 12:04:51', NULL, '348dee79d9d3fe0a8b260c1f9c47a289', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2W4G9K8ZF782W7H2TM5XA8', 'T-327', '2026-06-10 12:10:04', '2026-06-10 12:10:04', NULL, '967aab6bc692754a5d3a192cb0008871', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB37JZSFZKWPK9PDFYJY2YC0', 'T-328', '2026-06-10 13:00:06', '2026-06-10 13:00:06', NULL, '553b6683bbcddfbbab39ee545a99c98c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DHCTP001YCHFP39XER0ZM', 'T-329', '2026-06-10 13:26:06', '2026-06-10 13:26:06', NULL, 'a7b63cf73534de82c953d3029251b5e4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DJZDDZ00BSA04B660RS7M', 'T-330', '2026-06-10 13:26:19', '2026-06-10 13:26:19', NULL, 'c7ef552dc6d7fc2a5e312b20939a2987', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DKQQJ583944DG8561VQ3G', 'T-331', '2026-06-10 13:26:25', '2026-06-10 13:26:25', NULL, '002e4d81284119aecab05d180a9a41cd', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', 'T-332', '2026-06-10 13:26:31', '2026-06-10 13:26:31', NULL, '38d661bbd4a937daf395f82074cf8d12', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DN94MBCTYJW17ZCYVSXE0', 'T-333', '2026-06-10 13:26:38', '2026-06-10 13:26:38', NULL, 'c710c528662f92c6b7416b7163c8e99f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DNQZKV20F7YG5PJH8V8SM', 'T-334', '2026-06-10 13:26:42', '2026-06-10 13:26:42', NULL, '7f774dc51ae344eddfd42c2149887d56', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DP48FS33CQGRDF7EB9GT0', 'T-335', '2026-06-10 13:26:45', '2026-06-10 13:26:45', NULL, '1cce32ce77997202bf74c69426c29ab9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', 'T-336', '2026-06-10 13:26:56', '2026-06-10 13:26:56', NULL, 'b988abcd2cef0dbd6adbc56502f676ca', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'T-337', '2026-06-10 13:27:36', '2026-06-10 13:27:36', NULL, '94b0ebd244a6794e0e4e5a868b38d67f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JAXDKZMS0805MMEYB6820', 'T-338', '2026-06-10 13:47:04', '2026-06-10 13:47:04', NULL, 'be9b536d471a6008cc854092dfa6aaa4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JM0AXK1CTWD720RV1AVZ0', 'T-339', '2026-06-10 13:48:18', '2026-06-10 13:48:18', NULL, 'e76a2ce5d3149bb7ca0ec8a45f5c57a2', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3KS499THAD899M0NWN7E3R', 'T-340', '2026-06-10 13:53:23', '2026-06-10 13:53:23', NULL, 'bdd164a73576f6ca5151c8a62640526f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB44SKPKTHFMV6WD28GZYPXM', 'T-341', '2026-06-10 15:07:43', '2026-06-10 15:07:43', NULL, '113520f8f3a640fa2f470200ea9f45d8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB493JEW32CH0H3771TNHF7G', 'T-342', '2026-06-10 15:26:33', '2026-06-10 15:26:33', NULL, '13a646741953f113356c4bcda162c3c9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4FDREHRYRR7B9ER72KCQKC', 'T-343', '2026-06-10 15:54:09', '2026-06-10 15:54:09', NULL, 'e48d2b913bb03202409c6568d9bc11a5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4J5E6W983P1S7BE0FDPSMM', 'T-344', '2026-06-10 16:06:08', '2026-06-10 16:06:08', NULL, '481e82dfab98c20cd37c77a3e0c16668', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4RD1DDSYM4J7WYEGTXARB4', 'T-345', '2026-06-10 16:33:23', '2026-06-10 16:33:23', NULL, '9f26d5036c05057d4bd0fc53ddaddafd', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4VG3N3YJSV8G7M1HFSYW2W', 'T-346', '2026-06-10 16:46:54', '2026-06-10 16:46:54', NULL, 'a64940cb0b4ec4fb489eac27065dc447', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4XCM5KBXDDSCWJ37GPYG3R', 'T-347', '2026-06-10 16:55:10', '2026-06-10 16:55:10', NULL, '9d40226dbc6136072d2d5d0eda71f141', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB50YE6S6YWNP2ZSFWES9B2W', 'T-348', '2026-06-10 17:10:42', '2026-06-10 17:10:42', NULL, '8bb5ad92551f272417840869a0774668', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB58X0TFJ02YTMVPD0D9Q838', 'T-349', '2026-06-10 17:45:28', '2026-06-10 17:45:28', NULL, '0b12f14fa83254ab113c870531628359', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5CW7JPT6BR2RWMNYVCXJ50', 'T-350', '2026-06-10 18:02:50', '2026-06-10 18:02:50', NULL, 'a5c0c22d84621b14a5208317414d6026', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'T-351', '2026-06-10 18:23:41', '2026-06-10 18:23:41', NULL, '9d2da44c16c5aa38c0a36e4b00ef5f15', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5M14B76B31654D959XM5AC', 'T-352', '2026-06-10 18:34:05', '2026-06-10 18:34:05', NULL, '3fe3e1d5fb7c0fbd084b45116575ad98', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
File diff suppressed because it is too large Load Diff
+99
View File
@@ -16,6 +16,105 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
## [Unreleased]
## [2.3.1] — 2026-06-10
### Fixed
- **Frameless window chrome works on KDE Plasma 6 / KWin 6.** The Wayland
server-decoration request fired on `realize`, before GTK created the
surface, so it bailed and KWin (which defaults to server-side decorations)
kept drawing its own title bar. It now also fires on `map`. (T-351)
- **pql sidebar panes no longer stick on a transient startup error.** A
too-early or db-busy pql failure (the planning DB still settling, or a
SQLite lock under concurrent writes) is now retried a few times before
surfacing, instead of leaving the pane on "pql … failed" until a manual
refresh. (T-350)
- **Ticket and decision sidebars load on first open, not just after a manual
refresh.** On a desktop launch the daemon's pql workspace starts as the
launch directory, not the repo, so the panes' first fetch ran against the
wrong (or a stale-schema) DB and errored. They now re-fetch when the
workspace actually opens, by which point the pql workspace is the repo. (T-352)
## [2.3.0] — 2026-06-10
### Fixed
- **Tools like `pql` resolve when clide is launched from the desktop on Linux.**
A desktop launch inherits a minimal PATH without `~/.local/bin`, so the pql
pane (and other PATH-resolved tools) failed — the PATH expansion that fixes
this previously ran on macOS only. It now also runs on Linux. (T-347)
- **Consistent card font sizes in the Claude conversation.** Tool/result cards
and the Activity/run collapser cards now share the same header-label (14) and
collapsed-summary (13) sizes, so neighbouring cards in the stream no longer
render 12px apart. (T-344)
- **"Deny & simplify" no longer shows a loud red error.** A denial the user
deliberately chose (Deny & simplify) folds into a muted, collapsed "denied"
card instead of the prominent expanded-red block reserved for genuine tool
failures — which still render expanded. Driven by a reusable per-result
"quiet error" flag, not by matching the note text. (T-340)
- **Sub-agent prompts no longer render as a blue "you" card.** In live
(stream-json) sessions the spawning prompt is tagged with `parent_tool_use_id`,
not the transcript's `isSidechain`/`parentUuid`, so it slipped past the
sidechain fold. The parser now treats that field as a sidechain marker and
folds the prompt into its Agent card. (T-338)
### Added
- **Per-type filter chips on the tickets panel.** A row of toggle chips
(Initiative · Epic · Story · Task · Bug, large→small) below the filter box.
Click a chip to toggle that type; double-click to isolate it (chart-legend
solo); disabling the last one snaps all back on. ANDed with the text filter.
All on by default. (T-343)
- **VS Code keybinding preset.** A `vscode` keymap mapping VS Code's default
shortcuts (Ctrl+P, Ctrl+Shift+P, Ctrl+B, Ctrl+J, Ctrl+`, zoom, …) to clide.
Activate via the "Keymap: VS Code" command or `app.keymap.preset = vscode`. (T-64)
- **JetBrains keybinding preset.** A `jetbrains` keymap mapping IntelliJ's
defaults (Find Action, Go to File, tool windows, …). Double-Shift "Search
Everywhere" isn't expressible by the chord matcher yet (T-341), so Go to File
stands in for quick-open. (T-66)
- **Picking up a ticket now starts it.** Handing a ticket to a live Claude pane
(sidebar pick-up) also moves it to `in_progress` and refreshes the sidebar —
but only on acceptance and only from a not-yet-started status, so a pick-up
with no live pane is a quiet no-op and a re-pick-up never moves a ticket
backwards. (T-339)
- **Clickable file references in the Claude conversation.** Workspace file paths
mentioned by Claude — bare (`lib/app.dart`), with a line (`lib/app.dart:42`),
backticked, or as markdown links — are now clickable and open in the editor,
jumping to the line when present. Only paths that actually exist in the repo
linkify, so prose like version numbers stays literal. (T-300)
- **Hand a ticket to Claude from the sidebar.** Hovering a ticket card reveals a
run icon; clicking it hands the full ticket to the active Claude pane as a
"pick this up and start" prompt. Routed over the message bus, so the sidebar
stays decoupled from the session internals. (T-327)
- **Claude's task list is now visible, docked above the composer.** When Claude
is tracking a TodoWrite checklist, a compact display-only strip shows it pinned
above the input — collapsed to `N tasks · M done` + the current in-progress
item, expandable to the full list with per-item status glyphs. Hidden when
there are no tasks. (T-308)
- **A "Deny & simplify" option on the permission card.** A fourth button
(alongside Allow / Allow-and-remember / Deny) denies the action with a
preformatted note telling Claude it was too complex and to retry simpler —
without writing a memory or changing settings. A typed note is appended;
addressable by number key (4, or 3 without remember). (T-311)
### Fixed
- **Clicking outside the image in the lightbox now closes it.** Previously only
the thin margin dismissed — a click on the dimmed canvas beside a letterboxed
image hit the viewer and did nothing. A single tap outside the painted image
now closes it (matching Esc / the × button); tapping, dragging, or zooming the
image still doesn't. (T-309)
- **Run-status indicators no longer crash on rapid flips.** Switching status
back and forth within the 200ms cross-fade (e.g. running → success → running
across two bound Claude panes) tripped an AnimatedSwitcher duplicate-key
assertion and a cascade of follow-on errors. Each glyph now carries a key
unique per change, so an exiting and entering glyph never collide. (T-326)
- **The activity-card run-status spinner is now legible.** At 12px the spinning
logo mark read as a static speck; the run-status indicator on collapsible
cards is bumped to a `clideIconHero` (26) so the running state is clear at a
glance. The check / cross share the size, so the card doesn't jump on settle.
(T-304)
## [2.2.0] — 2026-06-10
### Added
+11
View File
@@ -76,10 +76,21 @@ make clean # remove build artefacts
One-time setup on a fresh clone: `make hooks && flutter pub get` once Flutter is installed.
### Tooling discipline
The `make` targets above are the entry points — run them, not the scripts they wrap. Check the changelog with `make changelog-gate`, never `ci/changelog_gate.sh` directly; same for `analyze`/`format`/`test`/`push-check`. The `make` layer sets up the environment and stays correct if a script moves.
Shell hygiene (keeps commands inside the permission allowlist, so they don't get denied mid-task):
- **Working directory is the repo root already** — don't prepend `cd /…/clide` or pass `git -C`. Just run the command.
- **One command per invocation** — no `&&`/`;` chaining and no multiple greps/echos in one call. The only exception is the `git commit -F` HEREDOC.
- Prefer the Read/Edit/Grep tools over `cat`/`sed`/`grep` for inspecting files.
## Git workflow
Commit and push directly to `main` for routine work — this is a solo-dev repo and does not use a branch-first / feature-branch flow. Do **not** create a working branch just to land a change. (This overrides the generic "branch before committing on the default branch" assistant default.) The usual safety rules still hold: never `--no-verify`, never force-push `main`, and let the pre-push gate run.
The pre-commit hook auto-exports and stages `.pql/changelog/` (the pql ticket DB) on every commit — don't hand-stage it. A ticket change only persists if the turn makes at least one commit; with no commit the hook never fires and a later branch switch can drop it.
## Changelog discipline
[Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/). Every user-visible commit adds an entry under `## [Unreleased]` in [`CHANGELOG.md`](CHANGELOG.md). Cutting a release means moving Unreleased entries under a new dated version heading **and** bumping `pubspec.yaml` `version:` in the same commit — see [`.claude/skills/git-commit/SKILL.md`](.claude/skills/git-commit/SKILL.md) for the full rule.
+95
View File
@@ -0,0 +1,95 @@
# clide JetBrains / IntelliJ keymap preset (T-66).
#
# Maps IntelliJ's default keybindings to clide intents + commands. A preset
# fully REPLACES the active layer, so this file is self-contained.
#
# Notation (D-82): `+` joins a chord, a space sequences, a YAML list
# alternates. IntelliJ's mac and Linux/Windows defaults diverge for several
# actions (Go to File is Cmd+Shift+O on mac but Ctrl+Shift+N on win/linux),
# so both spellings are bound.
#
# Scope flags: only `palette.open` / `quickOpen.open` are published today;
# IntelliJ's editor-scoped contexts have no producer yet, so global chords
# stay ungated (they're global in IntelliJ too).
#
# Not bound — no clide command analogue (kept out of scope per the ticket):
# Run (Shift+F10), Debug (Shift+F9), Rename/Refactor (Shift+F6), Settings
# (Ctrl+Alt+S). And "Search Everywhere" (double-Shift) is not expressible by
# the current chord matcher (bare/double modifiers unsupported) — tracked by
# T-341; Go to File / Find Action below are the practical stand-ins.
name: jetbrains
bindings:
# -- Activation / focus -----------------------------------------------
# No F6 panel cycling here: IntelliJ uses F6 (Move) / Shift+F6 (Rename),
# so binding them to panel focus would fight muscle memory.
- intent: activate
keys: [enter, space]
- intent: dismiss
keys: escape
- intent: focus.next
keys: tab
- intent: focus.previous
keys: shift+tab
# -- Find Action (≈ command palette): Ctrl+Shift+A --------------------
- intent: palette.open
keys: [ctrl+shift+a, meta+shift+a]
- intent: palette.selectNext
keys: down
when: palette.open
- intent: palette.selectPrevious
keys: up
when: palette.open
- intent: palette.accept
keys: enter
when: palette.open
- intent: dismiss
keys: escape
when: palette.open
# -- Quick open: Go to File / Go to Class / Recent Files --------------
# win/linux: Ctrl+Shift+N, Ctrl+N, Ctrl+E. mac: Cmd+Shift+O, Cmd+O,
# Cmd+E. clide has one fuzzy file finder, so all land on quick-open.
- intent: quickOpen.open
keys: [ctrl+shift+n, ctrl+n, ctrl+e, meta+shift+o, meta+o, meta+e]
when: "!palette.open"
- intent: quickOpen.selectNext
keys: down
when: quickOpen.open
- intent: quickOpen.selectPrevious
keys: up
when: quickOpen.open
- intent: quickOpen.accept
keys: enter
when: quickOpen.open
- intent: dismiss
keys: escape
when: quickOpen.open
# -- Find in Path (search): Ctrl+Shift+F ------------------------------
- intent: findInFiles.open
keys: [ctrl+shift+f, meta+shift+f]
# -- Tool windows -----------------------------------------------------
# Project (Alt+1) → sidebar; Terminal (Alt+F12) → bottom dock; Hide All
# Windows / distraction-free (Ctrl+Shift+F12) → focus (zen) mode.
- intent: command:sidebar.collapse
keys: [alt+1, meta+1]
- intent: command:dock.toggle
keys: alt+f12
- intent: command:panel.focusMode
keys: [ctrl+shift+f12, meta+shift+f12]
# -- Editor -----------------------------------------------------------
# Close active tab: Ctrl+F4 (win/linux) / Cmd+W (mac).
- intent: command:editor.close
keys: [ctrl+f4, meta+w]
# Zoom is a clide convenience — IntelliJ has no default zoom keys.
- intent: command:view.zoomIn
keys: [ctrl+equal, ctrl+shift+equal, meta+equal, meta+shift+equal]
- intent: command:view.zoomOut
keys: [ctrl+minus, meta+minus]
- intent: command:view.zoomReset
keys: [ctrl+0, meta+0]
+114
View File
@@ -0,0 +1,114 @@
# clide VS Code keymap preset (T-64).
#
# Maps VS Code's default keybindings to clide intents + commands so users
# with VS Code muscle memory feel at home. A preset fully REPLACES the
# active layer, so this file is self-contained (activation, focus, palette
# and quick-open navigation are all repeated from `default.yaml`).
#
# Notation (D-82): `+` joins a chord, a space sequences (`ctrl+k z`), a
# YAML list alternates (`[ctrl+b, meta+b]`). Both Ctrl (Linux/Windows) and
# Meta/Cmd (macOS) variants are bound so one preset serves every platform.
#
# Scope flags: only `palette.open` and `quickOpen.open` are published today
# (by the palette / quick-open overlays). VS Code's editor-scoped contexts
# (`editor.focused`, `inputFocused`) have no producer yet, so the global
# chords below stay ungated — which matches VS Code, where these commands
# (palette, quick-open, sidebar toggle, …) are global anyway. Editor-text-
# scoped gating lands when those scope producers do.
name: vscode
bindings:
# -- Activation / focus -----------------------------------------------
- intent: activate
keys: [enter, space]
- intent: dismiss
keys: escape
- intent: focus.next
keys: tab
- intent: focus.previous
keys: shift+tab
# Cycle the editor "parts" / panels (VS Code F6).
- intent: focus.nextPanel
keys: f6
- intent: focus.previousPanel
keys: shift+f6
# -- Command palette (Ctrl+Shift+P, F1) -------------------------------
- intent: palette.open
keys: [ctrl+shift+p, meta+shift+p, f1]
- intent: palette.selectNext
keys: [down, ctrl+n]
when: palette.open
- intent: palette.selectPrevious
keys: [up, ctrl+p]
when: palette.open
- intent: palette.accept
keys: enter
when: palette.open
- intent: dismiss
keys: escape
when: palette.open
# -- Quick open / Go to File (Ctrl+P) ---------------------------------
- intent: quickOpen.open
keys: [ctrl+p, meta+p]
when: "!palette.open"
- intent: quickOpen.selectNext
keys: [down, ctrl+n]
when: quickOpen.open
- intent: quickOpen.selectPrevious
keys: up
when: quickOpen.open
- intent: quickOpen.accept
keys: enter
when: quickOpen.open
- intent: dismiss
keys: escape
when: quickOpen.open
# -- Search (Ctrl+Shift+F) --------------------------------------------
- intent: findInFiles.open
keys: [ctrl+shift+f, meta+shift+f]
# -- Panel toggles ----------------------------------------------------
# Toggle Side Bar (Ctrl+B), Panel (Ctrl+J), Secondary Side Bar
# (Ctrl+Alt+B). clide's left sidebar / bottom dock / right context bar.
- intent: command:sidebar.collapse
keys: [ctrl+b, meta+b]
- intent: command:dock.toggle
keys: [ctrl+j, meta+j]
- intent: command:context.collapse
keys: [ctrl+alt+b, meta+alt+b]
# Toggle Terminal (Ctrl+`). clide has one bottom dock where shell/output
# live, so the terminal chord targets the same dock as Ctrl+J.
- intent: command:dock.toggle
keys: [ctrl+backquote, meta+backquote]
# Zen Mode (Ctrl+K Z).
- intent: command:panel.focusMode
keys: ["ctrl+k z", "meta+k z"]
# -- Editor actions ---------------------------------------------------
# Close Editor (Ctrl+W).
- intent: command:editor.close
keys: [ctrl+w, meta+w]
# Window zoom (VS Code View: Zoom In/Out/Reset). `+` is shift+equal on
# most layouts, so bind both equal and shift+equal.
- intent: command:view.zoomIn
keys: [ctrl+equal, ctrl+shift+equal, meta+equal, meta+shift+equal]
- intent: command:view.zoomOut
keys: [ctrl+minus, meta+minus]
- intent: command:view.zoomReset
keys: [ctrl+0, meta+0]
# -- File / workspace -------------------------------------------------
# Open Folder (Ctrl+K Ctrl+O), New Window (Ctrl+Shift+N), Close Folder
# (Ctrl+K F), Color Theme (Ctrl+K Ctrl+T).
- intent: command:file.openFolder
keys: ["ctrl+k ctrl+o", "meta+k meta+o"]
- intent: command:file.newWindow
keys: [ctrl+shift+n, meta+shift+n]
- intent: command:file.closeWorkspace
keys: ["ctrl+k f", "meta+k f"]
- intent: command:theme.pick
keys: ["ctrl+k ctrl+t", "meta+k meta+t"]
+3 -3
View File
@@ -39,7 +39,7 @@ self:
# Auto-synced from pubspec.yaml `version:` by `make gen-build-info`
# (runs implicitly on every build/run/test). Don't hand-edit; bump
# pubspec instead.
version: "2.1.0"
version: "2.3.1"
homepage: https://github.com/postmeridiem/clide
license: MIT
license_file: assets/LICENSE
@@ -144,7 +144,7 @@ dependencies:
purpose: >-
Incremental parsing library with embedded WASM grammar engine.
Vendored as libtree-sitter.so (wasmtime statically linked) in
app/native/. Called via dart:ffi. Loads grammar .wasm files
native/linux-x64/. Called via dart:ffi. Loads grammar .wasm files
through its built-in WASM store API.
- name: wasmtime
@@ -232,7 +232,7 @@ dev_dependencies:
- name: test
kind: dart-package
version: "1.30.0"
version: "1.31.0"
homepage: https://pub.dev/packages/test
license: BSD-3-Clause
purpose: >-
+14 -6
View File
@@ -41,15 +41,23 @@ dart test -r "$REPORTER" --concurrency=1 --tags pty test/pty/session_test.dart t
# serial-tagged tests (concurrency-vulnerable — run in their own --concurrency=1
# pass below). See dart_test.yaml + T-193.
if [[ "$coverage" == 1 ]]; then
# Each pass writes its raw coverage into a per-run temp dir (via
# --coverage-path), never the shared coverage/lcov.info / lcov.parallel.info.
# So a concurrent `flutter test --coverage` — a second push gate, or a
# `make test` during a push — can't race or delete this run's intermediates
# (which crashed merge_lcov with FileNotFoundError). Only the final merged
# result lands in coverage/lcov.info, via an atomic rename within coverage/.
# (T-345)
COV_TMP="$(mktemp -d "${TMPDIR:-/tmp}/clide-cov.XXXXXX")"
trap 'rm -rf "$COV_TMP" "coverage/.lcov.$$.info"' EXIT
echo "==> flutter test --coverage (parallel pool; excludes pty + serial)"
flutter test -r "$REPORTER" --coverage --exclude-tags "pty || serial" --timeout 60s
cp coverage/lcov.info coverage/lcov.parallel.info
flutter test -r "$REPORTER" --coverage --coverage-path "$COV_TMP/parallel.info" --exclude-tags "pty || serial" --timeout 60s
echo "==> flutter test --coverage (serial-tagged; --concurrency=1)"
flutter test -r "$REPORTER" --coverage --tags serial --concurrency=1 --timeout 60s
flutter test -r "$REPORTER" --coverage --coverage-path "$COV_TMP/serial.info" --tags serial --concurrency=1 --timeout 60s
echo "==> merge coverage (parallel + serial passes → coverage/lcov.info)"
python3 ci/merge_lcov.py coverage/lcov.parallel.info coverage/lcov.info > coverage/lcov.merged.info
mv coverage/lcov.merged.info coverage/lcov.info
rm -f coverage/lcov.parallel.info
mkdir -p coverage
python3 ci/merge_lcov.py "$COV_TMP/parallel.info" "$COV_TMP/serial.info" > "coverage/.lcov.$$.info"
mv -f "coverage/.lcov.$$.info" coverage/lcov.info
else
echo "==> flutter test (dev; parallel pool, excludes pty + serial)"
flutter test -r "$REPORTER" --exclude-tags "pty || serial" --concurrency=12 --timeout 60s
+17 -24
View File
@@ -1,13 +1,17 @@
#!/usr/bin/env bash
# ci/test_core.sh — run the Flutter-free core Dart tests.
#
# Covers `test/` at the repo root (IPC, daemon, PTY). Wraps `dart test`
# in a hard timeout + process-group kill so a hanging test (typically
# one holding a native fd open) can't wedge CI or pre-push.
# Covers `test/` at the repo root (IPC, daemon, PTY, git, panes, files,
# editor, pql). Each pass runs under `dart test --timeout` so a hanging
# test (typically one holding a native fd open) fails fast instead of
# wedging CI or the pre-push gate — the same portable mechanism ci/test.sh
# uses for the Flutter suite. No external `timeout`/`setsid` wrapper: those
# are GNU coreutils and absent on macOS, where their failure silently
# skipped the whole suite.
#
# Rationale: D-030 makes tests client-side only; a hang here is always
# local — either a real bug or a bad test. Either way we'd rather fail
# loudly at 120s than block a pre-push indefinitely.
# loudly at the per-test timeout than block a pre-push indefinitely.
set -euo pipefail
@@ -20,32 +24,21 @@ if ! command -v dart >/dev/null; then
exit 2
fi
# Hard timeout (seconds). The PTY tests should finish in <5s; IPC/daemon
# tests are faster still. 120s is generous for CI warmup, tiny for a
# hang.
TIMEOUT_SECONDS=${TIMEOUT_SECONDS:-120}
# Per-test hard timeout. The PTY tests should finish in <5s; IPC/daemon
# tests are faster still. 60s is generous for CI warmup, tiny for a hang.
# Matches ci/test.sh's --timeout 60s.
TEST_TIMEOUT="${TEST_TIMEOUT:-60s}"
# failures-only: print failing tests + a final count, not one line per test.
# Override with TEST_REPORTER=expanded when debugging. (T-242)
REPORTER="${TEST_REPORTER:-failures-only}"
# Run dart test in its own process group so we can kill descendants on
# timeout. `setsid` starts a new session; `timeout --kill-after` SIGKILLs
# after SIGTERM if the test ignores it.
CORE_DIRS="test/ipc test/pty test/daemon test/git test/panes test/files test/editor test/pql"
# Run a `dart test` pass under the hard timeout + process-group kill.
# Run a `dart test` pass under the per-test timeout. set -e propagates a
# failing pass (including a --timeout-induced failure) with dart's exit code.
run_pass() {
if ! timeout --kill-after=5s "${TIMEOUT_SECONDS}s" \
setsid --wait dart test -r "$REPORTER" "$@" ; then
rc=$?
if [[ $rc -eq 124 ]]; then
echo "test-core: TIMEOUT — killing descendants" >&2
pkill -9 -f "dart test" 2>/dev/null || true
exit 1
fi
exit $rc
fi
dart test -r "$REPORTER" --timeout "$TEST_TIMEOUT" "$@"
}
# Some core tests must not share the parallel pool:
@@ -58,10 +51,10 @@ run_pass() {
# record_id migration.)
# Run both in one --concurrency=1 pass (matching ci/test.sh's serial handling),
# then everything else in parallel.
echo "test-core: dart test (pty + serial; --concurrency=1) (timeout ${TIMEOUT_SECONDS}s)"
echo "test-core: dart test (pty + serial; --concurrency=1) (timeout ${TEST_TIMEOUT})"
run_pass --concurrency=1 --tags "pty || serial" $CORE_DIRS
echo "test-core: dart test (rest; parallel, excludes pty + serial) (timeout ${TIMEOUT_SECONDS}s)"
echo "test-core: dart test (rest; parallel, excludes pty + serial) (timeout ${TEST_TIMEOUT})"
run_pass --exclude-tags "pty || serial" $CORE_DIRS
echo "test-core: ok"
+12 -8
View File
@@ -33,9 +33,12 @@ One OS process. The Flutter app hosts:
- every subsystem handler (pane/files/editor/git/pql),
- the extension manager and all built-in extensions.
`tmux` is the only external long-lived process — it owns Claude
session persistence so panes survive app restarts (D-41). The app
re-attaches via `tmux new-session -A` on boot.
The Claude pane is driven over Claude Code's stream-json stdio control
protocol — clide spawns the `claude` child directly and renders its
event stream natively (D-75/D-77/D-78). Session continuity is
`--resume <session-id>` (state lives in Claude's transcript files), not
a long-lived wrapper process. `tmux` is no longer in the Claude path; it
is retained only by the general-purpose terminal builtin.
PTYs are spawned natively from Dart. `lib/src/pty/native_pty.dart`
calls `posix_openpt()` + `posix_spawn()` via FFI; the child inherits
@@ -74,11 +77,12 @@ state-changing command emits one or more events on a long-lived
event stream; every UI affordance has a matching CLI verb. See D-6
for the subsystem/verb/event contract.
> **Caveat (2026-05):** the Unix-socket server that exposes the
> dispatcher to a thin `clide` C client is currently unimplemented.
> Today's working path is in-process direct dispatch. See **T-99**
> (IPC server implementation) and **D-68** (dual integration surface
> — Bash CLI primary, MCP secondary).
The Unix-socket server that exposes the dispatcher to a thin `clide` C
client (`native/clide-cli/clide.c`) is implemented in
`lib/src/ipc/server.dart`; the socket path, access control, and dispatch
model are pinned by D-70/D-71/D-72. In-process direct dispatch remains
the path for the Flutter app's own subsystem calls. See **D-68** (dual
integration surface — Bash CLI primary, MCP secondary).
### User-facing — Flutter desktop
+3 -3
View File
@@ -79,8 +79,8 @@ The widget is a thin shell:
- Calls `bodyBuilder(active)` for the visible content
- Routes user gestures to controller methods or callbacks
- Emits `onCloseRequested` / `onAddRequested` so the host decides
the actual lifecycle (e.g. Claude pane spawns a new tmux session,
doesn't just append a UI tab)
the actual lifecycle (e.g. the Claude pane spawns a new stream-json
session, doesn't just append a UI tab)
The host owns the controller and the payload type. The widget never
touches PTY, IPC, or Claude session naming.
@@ -138,7 +138,7 @@ ClaudePane (host)
)
```
`ClaudeSessionRef` carries the tmux session name + isPrimary. The
`ClaudeSessionRef` carries the stream-json session id + isPrimary. The
controller is seeded with `[primary]` on boot; secondaries get
appended as the user clicks `+`. Closing a secondary triggers
`pane.close` IPC and removes the entry; closing the primary is not
@@ -0,0 +1,254 @@
{
"name": "Ticket Panel — Type Filters",
"shapes": {
"panel": {
"type": "Rectangle",
"left": 80, "top": 60, "width": 400, "height": 520,
"fillColor": "#0d0d11",
"strokeColor": "#23232b",
"corners": [6, 6, 6, 6]
},
"filter-box": {
"type": "Rectangle",
"parent": "panel",
"left": 96, "top": 76, "width": 368, "height": 30,
"fillColor": "#16161c",
"strokeColor": "#2c2c36",
"corners": [5, 5, 5, 5]
},
"filter-icon": {
"type": "Text",
"parent": "filter-box",
"left": 108, "top": 84,
"text": "⌕",
"fontColor": "#5b5b66",
"fontSize": 13
},
"filter-text": {
"type": "Text",
"parent": "filter-box",
"left": 126, "top": 85,
"text": "Filter tickets…",
"fontColor": "#5b5b66",
"fontSize": 12
},
"refresh-icon": {
"type": "Text",
"parent": "filter-box",
"left": 442, "top": 84,
"text": "↻",
"fontColor": "#5b5b66",
"fontSize": 13
},
"chip-init": {
"type": "Rectangle",
"parent": "panel",
"left": 96, "top": 118, "width": 86, "height": 24,
"fillColor": "#1a151f",
"strokeColor": "#C792EA",
"corners": [4, 4, 4, 4]
},
"chip-init-dot": {
"type": "Ellipse",
"parent": "chip-init",
"left": 104, "top": 126, "width": 8, "height": 8,
"fillColor": "#C792EA",
"strokeColor": "#C792EA"
},
"chip-init-label": {
"type": "Text",
"parent": "chip-init",
"left": 117, "top": 124,
"text": "Initiative",
"fontColor": "#e8e8ee",
"fontSize": 11
},
"chip-epic": {
"type": "Rectangle",
"parent": "panel",
"left": 188, "top": 118, "width": 58, "height": 24,
"fillColor": "#14171f",
"strokeColor": "#78A0F8",
"corners": [4, 4, 4, 4]
},
"chip-epic-dot": {
"type": "Ellipse",
"parent": "chip-epic",
"left": 196, "top": 126, "width": 8, "height": 8,
"fillColor": "#78A0F8",
"strokeColor": "#78A0F8"
},
"chip-epic-label": {
"type": "Text",
"parent": "chip-epic",
"left": 209, "top": 124,
"text": "Epic",
"fontColor": "#e8e8ee",
"fontSize": 11
},
"chip-story": {
"type": "Rectangle",
"parent": "panel",
"left": 252, "top": 118, "width": 62, "height": 24,
"fillColor": "#15191d",
"strokeColor": "#7DD3A8",
"corners": [4, 4, 4, 4]
},
"chip-story-dot": {
"type": "Ellipse",
"parent": "chip-story",
"left": 260, "top": 126, "width": 8, "height": 8,
"fillColor": "#7DD3A8",
"strokeColor": "#7DD3A8"
},
"chip-story-label": {
"type": "Text",
"parent": "chip-story",
"left": 273, "top": 124,
"text": "Story",
"fontColor": "#e8e8ee",
"fontSize": 11
},
"chip-task": {
"type": "Rectangle",
"parent": "panel",
"left": 320, "top": 118, "width": 56, "height": 24,
"fillColor": "#17181a",
"strokeColor": "#9AA0AA",
"corners": [4, 4, 4, 4]
},
"chip-task-dot": {
"type": "Ellipse",
"parent": "chip-task",
"left": 328, "top": 126, "width": 8, "height": 8,
"fillColor": "#9AA0AA",
"strokeColor": "#9AA0AA"
},
"chip-task-label": {
"type": "Text",
"parent": "chip-task",
"left": 341, "top": 124,
"text": "Task",
"fontColor": "#e8e8ee",
"fontSize": 11
},
"chip-bug": {
"type": "Rectangle",
"parent": "panel",
"left": 382, "top": 118, "width": 54, "height": 24,
"fillColor": "#211519",
"strokeColor": "#E87D7D",
"corners": [4, 4, 4, 4]
},
"chip-bug-dot": {
"type": "Ellipse",
"parent": "chip-bug",
"left": 390, "top": 126, "width": 8, "height": 8,
"fillColor": "#E87D7D",
"strokeColor": "#E87D7D"
},
"chip-bug-label": {
"type": "Text",
"parent": "chip-bug",
"left": 403, "top": 124,
"text": "Bug",
"fontColor": "#e8e8ee",
"fontSize": 11
},
"divider": {
"type": "Line",
"parent": "panel",
"left": 96, "top": 158, "width": 368, "height": 1,
"strokeColor": "#23232b"
},
"section-header": {
"type": "Text",
"parent": "panel",
"left": 98, "top": 172,
"text": "▾ BACKLOG · 61",
"fontColor": "#6b6b76",
"fontSize": 10
},
"card-1": {
"type": "Rectangle",
"parent": "panel",
"left": 96, "top": 192, "width": 368, "height": 56,
"fillColor": "#0d0d11",
"strokeColor": "#23232b",
"corners": [4, 4, 4, 4]
},
"card-1-dot": {
"type": "Ellipse",
"parent": "card-1",
"left": 108, "top": 204, "width": 8, "height": 8,
"fillColor": "#C792EA",
"strokeColor": "#C792EA"
},
"card-1-id": {
"type": "Text",
"parent": "card-1",
"left": 122, "top": 200,
"text": "T-8",
"fontColor": "#e8e8ee",
"fontSize": 11
},
"card-1-title": {
"type": "Text",
"parent": "card-1",
"left": 108, "top": 220,
"text": "Tier 6 — extension API, settings, theming, builds",
"fontColor": "#b8b8c2",
"fontSize": 11
},
"card-2": {
"type": "Rectangle",
"parent": "panel",
"left": 96, "top": 254, "width": 368, "height": 56,
"fillColor": "#0d0d11",
"strokeColor": "#23232b",
"corners": [4, 4, 4, 4]
},
"card-2-dot": {
"type": "Ellipse",
"parent": "card-2",
"left": 108, "top": 266, "width": 8, "height": 8,
"fillColor": "#E87D7D",
"strokeColor": "#E87D7D"
},
"card-2-id": {
"type": "Text",
"parent": "card-2",
"left": 122, "top": 262,
"text": "T-19",
"fontColor": "#e8e8ee",
"fontSize": 11
},
"card-2-title": {
"type": "Text",
"parent": "card-2",
"left": 108, "top": 282,
"text": "Filter box loses focus on refresh tick",
"fontColor": "#b8b8c2",
"fontSize": 11
},
"caption": {
"type": "Text",
"parent": "panel",
"left": 96, "top": 540,
"text": "All five type filters ON by default — click a chip to toggle, double-click to isolate.",
"fontColor": "#56565f",
"fontSize": 10
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+4 -4
View File
@@ -8,10 +8,10 @@ on any Linux or macOS dev box without network access or shared state.
| layer | location | runner | time | when |
|---|---|---|---|---|
| unit (root) | `test/` | `dart test` | ~5s | `make test` |
| unit + widget + golden (app) | `app/test/` | `flutter test` | ~30s | `make test` |
| a11y contract | `app/test/a11y/` | `flutter test` | ~5s | `make test-a11y` |
| integration (startup gate) | `app/integration_test/` | `flutter test integration_test/` | ~60s | `make test-integration` |
| unit (Flutter-free core) | `test/ipc/`, `test/daemon/`, `test/pty/` | `dart test` | ~5s | `make test-core` |
| unit + widget + golden | `test/` | `flutter test` | ~30s | `make test` |
| a11y contract | `test/a11y/` | `flutter test` | ~5s | `make test-a11y` |
| integration (startup gate) | `integration_test/` | `flutter test integration_test/` | ~60s | `make test-integration` |
| daemon E2E + web WASM smoke | `test/daemon/` + `tools/ui/tests/` | `dart test` + Playwright | ~60s | `make test-e2e` |
| startup bundle smoke | `ci/smoke_bundle.sh` | xvfb-run, 5s timeout | ~30s | `make smoke-bundle` |
+1 -1
View File
@@ -4,7 +4,7 @@ Claude Code needs to *actually use* the app while building features.
The pipeline:
1. **Flutter builds the app to WASM.** `flutter build web --wasm` ships
a CanvasKit/Skwasm bundle under `app/build/web/`.
a CanvasKit/Skwasm bundle under `build/web/`.
2. **A local server serves it.** `tools/ui/serve.sh` starts
`http://localhost:4280` in the background with a pidfile.
3. **Playwright drives a headless Chromium.** Instead of click-by-pixel
+9
View File
@@ -9,6 +9,7 @@ import 'claude_banner.dart';
import 'claude_composer.dart';
import 'claude_config.dart';
import 'claude_status.dart';
import 'claude_task_dock.dart';
import 'clipboard_paste.dart';
import 'activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey;
import 'conversation_controller.dart';
@@ -21,6 +22,7 @@ import 'session_orchestrator.dart';
import 'session_picker.dart';
import 'slash_commands.dart';
import 'stream_json_session.dart';
import 'task_list.dart';
import 'transcript_reader.dart';
/// The Claude conversation pane. Drives `claude` over the stream-json control
@@ -505,6 +507,7 @@ class _ClaudePaneState extends State<ClaudePane> {
foldLevel: foldLevelFromName(_kernel()?.settings.get<String>(kActivityFoldLevelKey)),
hiddenToolUseIds: _session?.promptedToolUseIds ?? const <String>{},
toolUseOutcomes: _session?.toolUseOutcomes ?? const <String, bool>{},
quietErrorToolUseIds: _session?.quietErrorToolUseIds ?? const <String>{},
emptyState: ClaudeBanner(
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
workspace: _repoRoot,
@@ -513,6 +516,12 @@ class _ClaudePaneState extends State<ClaudePane> {
),
),
),
// Claude's task list, docked above the composer (T-308). Rebuilds
// with the conversation; renders nothing when there are no tasks.
ListenableBuilder(
listenable: _conversation!,
builder: (_, __) => ClaudeTaskDock(tasks: taskListFrom(_conversation!.items)),
),
// An open prompt takes the composer's space and hides the text
// input until it's answered, so interaction stays out of the
// conversation stream (D-78).
@@ -0,0 +1,123 @@
/// Claude's task list, docked above the composer (T-308).
///
/// A compact, display-only surface (D-78 — not an interactive control) pinned
/// between the conversation and the composer so the user can always see what
/// Claude is tracking and how far along it is. Collapsed by default to a
/// one-line summary (`N tasks · M done` + the current in-progress item);
/// tapping expands the full checklist. Renders nothing when there are no tasks.
library;
import 'package:clide/builtin/claude/src/task_list.dart';
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class ClaudeTaskDock extends StatefulWidget {
const ClaudeTaskDock({super.key, required this.tasks});
final List<TaskItem> tasks;
@override
State<ClaudeTaskDock> createState() => _ClaudeTaskDockState();
}
class _ClaudeTaskDockState extends State<ClaudeTaskDock> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final tasks = widget.tasks;
if (tasks.isEmpty) return const SizedBox.shrink(); // no chrome when empty
final tokens = ClideTheme.of(context).surface;
final done = tasks.where((t) => t.status == TaskStatus.completed).length;
final inProgress = tasks.where((t) => t.status == TaskStatus.inProgress);
final current = inProgress.isEmpty ? null : inProgress.first.text;
final summary = '${tasks.length} task${tasks.length == 1 ? '' : 's'} · $done done';
return Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 10, 6),
child: ClideTappable(
onTap: () => setState(() => _expanded = !_expanded),
tooltip: _expanded ? 'Collapse tasks' : 'Expand tasks',
builder: (context, hovered, focused) => Container(
decoration: BoxDecoration(
color: (hovered || focused) ? tokens.listItemHoverBackground : tokens.listItemBackground,
border: Border.all(color: tokens.panelBorder),
borderRadius: BorderRadius.circular(4),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// The summary row IS the toggle — a single labelled button node
// (its inner text is announced via the label, so exclude it).
Semantics(
button: true,
label: 'Claude task list, $summary, ${_expanded ? 'expanded' : 'collapsed'}',
excludeSemantics: true,
child: _summaryRow(tokens, summary, current),
),
if (_expanded) ...[
ClideDivider(),
Padding(
padding: const EdgeInsets.fromLTRB(10, 4, 10, 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [for (final t in tasks) _taskRow(tokens, t)],
),
),
],
],
),
),
),
);
}
Widget _summaryRow(SurfaceTokens tokens, String summary, String? current) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
ClideIcon(_expanded ? const ChevronDownIcon() : const ChevronRightIcon(), size: 12, color: tokens.globalTextMuted),
const SizedBox(width: 8),
ClideText(summary, fontSize: clideFontCaption, color: tokens.globalTextMuted),
if (!_expanded && current != null) ...[
const SizedBox(width: 10),
Expanded(child: ClideText(current, fontSize: clideFontCaption, color: tokens.globalTextMuted, maxLines: 1, overflow: TextOverflow.ellipsis)),
] else
const Spacer(),
],
),
);
Widget _taskRow(SurfaceTokens tokens, TaskItem t) {
final (String glyph, Color color, String word) = switch (t.status) {
TaskStatus.completed => ('check-circle', tokens.statusSuccess, 'done'),
TaskStatus.inProgress => ('circle-half', tokens.globalFocus, 'in progress'),
TaskStatus.pending => ('circle', tokens.globalTextMuted, 'pending'),
};
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Semantics(
label: '${t.text}, $word',
container: true,
excludeSemantics: true,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(padding: const EdgeInsets.only(top: 1), child: ClideIcon(PhosphorIcons.byName(glyph), size: 13, color: color)),
const SizedBox(width: 8),
Expanded(
child: ClideText(
t.text,
fontSize: clideFontCaption,
color: t.status == TaskStatus.completed ? tokens.globalTextMuted : tokens.globalForeground,
),
),
],
),
),
);
}
}
@@ -246,7 +246,9 @@ class _ConversationCardState extends State<ConversationCard> {
return Row(
children: [
if (widget.collapsible) _caret(tokens),
ClideText(widget.label, fontSize: clideFontSmall, color: widget.accent, fontFamily: clideMonoFamily),
// Header label size matches ClideCollapserCard (clideFontCaption) so
// neighbouring cards in the conversation stream align (T-344).
ClideText(widget.label, fontSize: clideFontCaption, color: widget.accent, fontFamily: clideMonoFamily),
// While collapsed, show a one-line gist next to the label so the card
// still says what it holds.
if (_collapsed && summary != null) ...[
+85 -8
View File
@@ -11,6 +11,7 @@ library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:clide/builtin/claude/src/activity_cluster.dart';
import 'package:clide/builtin/claude/src/conversation_card.dart';
@@ -33,6 +34,7 @@ class ConversationView extends StatefulWidget {
this.emptyState,
this.hiddenToolUseIds = const <String>{},
this.toolUseOutcomes = const <String, bool>{},
this.quietErrorToolUseIds = const <String>{},
this.foldLevel = FoldLevel.tools,
});
@@ -52,6 +54,13 @@ class ConversationView extends StatefulWidget {
/// border instead of being hidden (D-78).
final Map<String, bool> toolUseOutcomes;
/// tool_use_ids whose error result should render folded + muted instead of as
/// a loud red failure (T-340) — expected, user-initiated denials the user
/// already understands (Deny & simplify). Genuine tool errors (ids not in
/// here) keep the prominent expanded-red treatment (T-168). A reusable filter:
/// add ids to quiet more error kinds without string-matching their text.
final Set<String> quietErrorToolUseIds;
/// Whether to wrap the list in its own [ClideSelectionArea]. The team
/// grid sets this false and wraps all tiles in one shared area so
/// selection spans tiles — nesting SelectionAreas is illegal (T-140).
@@ -181,6 +190,13 @@ class _ConversationViewState extends State<ConversationView> {
for (final it in items)
if (it is AssistantToolUse && _isAgentTool(it.name)) it.uuid: it,
};
// Stream-json tags sidechain items with the spawning Agent's tool-use id
// directly (T-338), so map Agent cards by tool-use id for a direct lookup
// that doesn't depend on the (transcript-only) parentUuid chain.
final agentByToolUseId = <String, AssistantToolUse>{
for (final it in items)
if (it is AssistantToolUse && _isAgentTool(it.name)) it.toolUseId: it,
};
// Envelope-level chain info (consistent across items sharing a uuid).
final parentByUuid = <String, String?>{};
final sidechainByUuid = <String, bool>{};
@@ -192,6 +208,13 @@ class _ConversationViewState extends State<ConversationView> {
}
AssistantToolUse? resolveOwner(ConversationItem item, AssistantToolUse? nearest) {
// Direct route: stream-json hands us the spawning Agent's tool-use id on
// the item itself (T-338) — no chain to walk.
final byTool = item.parentToolUseId;
if (byTool != null) {
final agent = agentByToolUseId[byTool];
if (agent != null) return agent;
}
var cur = item.uuid;
final seen = <String>{};
while (seen.add(cur)) {
@@ -290,6 +313,7 @@ class _ConversationViewState extends State<ConversationView> {
tokens: tokens,
collapseTools: true,
toolUseOutcomes: widget.toolUseOutcomes,
quietErrorToolUseIds: widget.quietErrorToolUseIds,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
@@ -300,6 +324,7 @@ class _ConversationViewState extends State<ConversationView> {
items: items,
tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes,
quietErrorToolUseIds: widget.quietErrorToolUseIds,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
@@ -310,6 +335,7 @@ class _ConversationViewState extends State<ConversationView> {
edits: edits,
tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes,
quietErrorToolUseIds: widget.quietErrorToolUseIds,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
@@ -365,6 +391,31 @@ void _openUrl(BuildContext context, String url) {
unawaited(ClideKernel.of(context).os.openURL(url));
}
/// Resolve a path-like token from the conversation to an absolute workspace
/// file, or null if it doesn't name a real repo file (T-300). Delegates the
/// (pure, testable) path logic to [resolveWorkspaceFilePath] with the open
/// project root.
String? _resolveRepoFile(BuildContext context, String raw) => resolveWorkspaceFilePath(ClideKernel.of(context).project.current?.path, raw);
/// Resolve [raw] (a path-like token) against the workspace [root] to an absolute
/// path, or null if it doesn't name a real file under the repo (T-300). The
/// existence check is what keeps prose ("e.g.", "2.2.0") from linkifying.
/// Relative tokens resolve against [root]; absolute tokens must already live
/// inside it. `..` segments are rejected so a ref can't escape the repo.
@visibleForTesting
String? resolveWorkspaceFilePath(String? root, String raw) {
if (root == null || raw.isEmpty || raw.contains('..')) return null;
final abs = raw.startsWith('/') ? raw : '$root/$raw';
if (!abs.startsWith('$root/')) return null;
return File(abs).existsSync() ? abs : null;
}
/// Open a clicked workspace file reference in the editor, jumping to [line]
/// when present — the Dart-side twin of `clide editor open <path>` (T-300, D-6).
void _openFile(BuildContext context, String path, int? line) {
unawaited(ClideKernel.of(context).ipc.request('editor.open', args: {'path': path, if (line != null) 'line': line}));
}
/// One conversation item, rendered by kind.
class _ConversationTurn extends StatelessWidget {
const _ConversationTurn({
@@ -373,6 +424,7 @@ class _ConversationTurn extends StatelessWidget {
required this.tokens,
this.collapseTools = false,
this.toolUseOutcomes = const <String, bool>{},
this.quietErrorToolUseIds = const <String>{},
this.toolUseById = const <String, AssistantToolUse>{},
this.resultByToolUseId = const <String, ToolResultMessage>{},
this.promptsByToolUseId = const <String, List<UserMessage>>{},
@@ -393,6 +445,10 @@ class _ConversationTurn extends StatelessWidget {
EdgeInsetsGeometry get _childMargin => collapseTools ? const EdgeInsets.only(bottom: 14) : const EdgeInsets.only(bottom: kClideCardHeaderPadH);
final Map<String, bool> toolUseOutcomes;
/// tool_use_ids whose error result folds quietly instead of expanded-red
/// (T-340) — see [ConversationView.quietErrorToolUseIds].
final Set<String> quietErrorToolUseIds;
/// Index from toolUseId → AssistantToolUse, for result-card pairing (T-168).
final Map<String, AssistantToolUse> toolUseById;
@@ -443,6 +499,8 @@ class _ConversationTurn extends StatelessWidget {
onRecordTap: (id) => _openRecord(context, id),
onImageToken: (path) => ImageThumbnail(path: path, size: 48),
onLinkTap: (url) => _openUrl(context, url),
resolveFileRef: (p) => _resolveRepoFile(context, p),
onOpenFile: (path, line) => _openFile(context, path, line),
),
),
// Sub-agent (sidechain) prose is NOT the main Claude — attribute it to the
@@ -453,7 +511,13 @@ class _ConversationTurn extends StatelessWidget {
label: i.isSidechain ? 'agent' : 'claude',
copyText: i.text,
margin: _childMargin,
body: ClideMarkdown(i.text, onRecordTap: (id) => _openRecord(context, id), onLinkTap: (url) => _openUrl(context, url)),
body: ClideMarkdown(
i.text,
onRecordTap: (id) => _openRecord(context, id),
onLinkTap: (url) => _openUrl(context, url),
resolveFileRef: (p) => _resolveRepoFile(context, p),
onOpenFile: (path, line) => _openFile(context, path, line),
),
),
AssistantThinkingMessage() => ConversationCard(
// Framed + muted like the context card (T-306).
@@ -585,6 +649,7 @@ class _ConversationTurn extends StatelessWidget {
item: r,
tokens: tokens,
toolUseOutcomes: toolUseOutcomes,
quietErrorToolUseIds: quietErrorToolUseIds,
toolUseById: toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: promptsByToolUseId,
@@ -675,22 +740,28 @@ class _ConversationTurn extends StatelessWidget {
// Error result: render the error message prominently (T-168). If we have
// the paired tool_use, show the tool name as a sub-label so the user can
// see what failed without expanding.
//
// Exception (T-340): an expected, user-initiated denial (Deny & simplify)
// is noise as a loud red error — the user already knows what they did. Fold
// it to a muted, collapsed card. Genuine failures keep the expanded-red look.
if (t.isError) {
final quiet = quietErrorToolUseIds.contains(t.toolUseId);
final multiline = t.content.contains('\n');
final errLabel = quiet ? 'denied' : label;
return ConversationCard(
variant: ConversationCardVariant.bordered,
accent: accent,
borderColor: tokens.statusError,
label: paired != null ? '${paired.name} · $label' : label,
accent: quiet ? tokens.globalTextMuted : accent,
borderColor: quiet ? tokens.panelBorder : tokens.statusError,
label: paired != null ? '${paired.name} · $errLabel' : errLabel,
copyText: t.content,
collapsible: multiline,
collapsedByDefault: false, // errors default expanded so they're visible
collapsedSummary: multiline ? _firstLine(t.content) : null,
collapsible: quiet || multiline,
collapsedByDefault: quiet, // genuine errors stay expanded; a denial folds
collapsedSummary: (quiet || multiline) ? _firstLine(t.content) : null,
body: ClideText(
t.content,
fontSize: clideFontMeta,
fontFamily: clideMonoFamily,
color: tokens.statusError,
color: quiet ? tokens.globalTextMuted : tokens.statusError,
),
);
}
@@ -748,6 +819,7 @@ class _ActivityCard extends StatelessWidget {
required this.items,
required this.tokens,
required this.toolUseOutcomes,
required this.quietErrorToolUseIds,
required this.toolUseById,
required this.resultByToolUseId,
required this.promptsByToolUseId,
@@ -757,6 +829,7 @@ class _ActivityCard extends StatelessWidget {
final List<ConversationItem> items;
final SurfaceTokens tokens;
final Map<String, bool> toolUseOutcomes;
final Set<String> quietErrorToolUseIds;
final Map<String, AssistantToolUse> toolUseById;
final Map<String, ToolResultMessage> resultByToolUseId;
final Map<String, List<UserMessage>> promptsByToolUseId;
@@ -777,6 +850,7 @@ class _ActivityCard extends StatelessWidget {
item: item,
tokens: tokens,
toolUseOutcomes: toolUseOutcomes,
quietErrorToolUseIds: quietErrorToolUseIds,
toolUseById: toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: promptsByToolUseId,
@@ -809,6 +883,7 @@ class _EditRunCard extends StatelessWidget {
required this.edits,
required this.tokens,
required this.toolUseOutcomes,
required this.quietErrorToolUseIds,
required this.toolUseById,
required this.resultByToolUseId,
required this.promptsByToolUseId,
@@ -818,6 +893,7 @@ class _EditRunCard extends StatelessWidget {
final List<ConversationItem> edits;
final SurfaceTokens tokens;
final Map<String, bool> toolUseOutcomes;
final Set<String> quietErrorToolUseIds;
final Map<String, AssistantToolUse> toolUseById;
final Map<String, ToolResultMessage> resultByToolUseId;
final Map<String, List<UserMessage>> promptsByToolUseId;
@@ -838,6 +914,7 @@ class _EditRunCard extends StatelessWidget {
item: item,
tokens: tokens,
toolUseOutcomes: toolUseOutcomes,
quietErrorToolUseIds: quietErrorToolUseIds,
toolUseById: toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: promptsByToolUseId,
+14
View File
@@ -11,6 +11,7 @@ import 'package:clide/builtin/claude/src/pane_context_status.dart';
import 'package:clide/builtin/claude/src/claude_meta_sidebar.dart';
import 'package:clide/builtin/claude/src/session_index.dart';
import 'package:clide/builtin/claude/src/session_storage.dart';
import 'package:clide/builtin/claude/src/ticket_pick_up.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart' show ImageMessage;
import 'package:clide/src/daemon/image_commands.dart' show imageShowChannel;
import 'package:clide/builtin/claude/src/team_chat_sidebar.dart' show TeamChatPane;
@@ -348,6 +349,19 @@ class ClaudeExtension extends ClideExtension {
// 'image' message; we inject the matching card into the conversation the
// user is looking at (the primary lead, else the first visible session).
_subs.add(ctx.messages.subscribe(channel: imageShowChannel).listen(_onImageShow));
// A sidebar "pick up" click (T-327) publishes the full ticket; inject it
// into the active conversation as a user turn so Claude starts working it.
_subs.add(ctx.messages.subscribe(publisher: 'builtin.tickets', channel: 'pick-up').listen(_onTicketPickUp));
}
/// Hand a picked-up ticket to the active Claude session (T-327/T-339). The
/// decision + transition live in [applyTicketPickUp] so they're testable
/// without the activation machinery.
void _onTicketPickUp(Message m) {
final ctx = _ctx;
if (ctx == null) return;
unawaited(applyTicketPickUp(m.data, orchestrator: _orchestrator, ipc: ctx.ipc, messages: ctx.messages));
}
/// Close every session that doesn't belong to the newly-active workspace
+29 -1
View File
@@ -1,5 +1,6 @@
/// The interactive prompt surface for the stream-json control channel
/// (T-166, T-175, T-176, D-78): a permission Allow / Allow-and-remember / Deny,
/// (T-166, T-175, T-176, D-78): a permission Allow / Allow-and-remember / Deny /
/// Deny-and-simplify (T-311),
/// or an `AskUserQuestion` picker (single = bare; multi = stepper + review).
/// Rendered in the composer zone (not inline in the conversation) so
/// interaction and conversation widgets don't mix — the pane swaps it in for
@@ -23,6 +24,15 @@ import 'package:flutter/widgets.dart';
/// Sentinel option key for the always-present free-text "Other…" choice.
const _kOther = '\u0000other';
/// Preformatted note for the "Deny & simplify" permission option (T-311): deny
/// THIS action and ask Claude to reformulate it more simply, explicitly without
/// touching the permission surface (memories / settings).
const _kDenySimplifyNote = '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. Do not narrate or explain the change; '
'proceed silently with the simpler version.';
class ToolPromptCard extends StatefulWidget {
const ToolPromptCard({super.key, required this.prompt, required this.onResolve});
@@ -177,6 +187,7 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
if (n == 1) return _then(_permAllow);
if (canRemember && n == 2) return _then(() => _permAllow(remember: true));
if (n == (canRemember ? 3 : 2)) return _then(_permDeny);
if (n == (canRemember ? 4 : 3)) return _then(_permDenySimplify);
return false;
}
final qi = _currentQuestion();
@@ -216,6 +227,18 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
void _permDeny() => widget.onResolve(widget.prompt.promptId, DenyTool(_permNote() ?? 'Denied by the user.'));
/// Deny carrying a preformatted "this was too complex — retry simpler" note
/// (T-311). The "do not add a memory / change settings" clause keeps Claude
/// from trying to "fix" the permission surface instead of reformulating; the
/// user's own typed note, if any, is appended rather than discarded.
void _permDenySimplify() {
final user = _permNote();
final note = user == null ? _kDenySimplifyNote : '$_kDenySimplifyNote\n\nUser note: $user';
// Quiet: the user deliberately chose this, so its denial folds rather than
// shouting as a red error (T-340).
widget.onResolve(widget.prompt.promptId, DenyTool(note, quiet: true));
}
(Color, String, List<Widget>) _permission(SurfaceTokens tokens) {
final p = widget.prompt;
final canRemember = p.permissionSuggestions.isNotEmpty;
@@ -245,6 +268,11 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
ClideButton(label: '1. Allow', variant: ClideButtonVariant.primary, onPressed: () => _permAllow()),
if (canRemember) ClideButton(label: "2. Allow & don't ask again", onPressed: () => _permAllow(remember: true)),
ClideButton(label: '${canRemember ? '3' : '2'}. Deny', onPressed: _permDeny),
ClideButton(
label: '${canRemember ? '4' : '3'}. Deny & simplify',
tooltip: 'Deny and ask Claude to retry this action in a simpler format — complex interactions don\'t work well with the permission system.',
onPressed: _permDenySimplify,
),
],
),
const SizedBox(height: 10),
@@ -183,9 +183,15 @@ final class AllowTool extends ToolDecision {
}
/// Deny the tool with a user-facing [message] (required by the protocol).
///
/// [quiet] marks a deliberate, user-initiated denial that the user already
/// understands (e.g. "Deny & simplify", T-340) — the resulting error tool-result
/// should fold to a muted card rather than shout as a red failure. Off by
/// default, so a genuine/unexpected denial still renders prominently.
final class DenyTool extends ToolDecision {
const DenyTool(this.message);
const DenyTool(this.message, {this.quiet = false});
final String message;
final bool quiet;
@override
Map<String, dynamic> toJson() => {'behavior': 'deny', 'message': message};
}
@@ -246,9 +252,16 @@ class StreamJsonSession {
/// green/red border (D-78).
final _toolUseOutcome = <String, bool>{};
/// tool_use_ids whose error result should render folded + muted rather than as
/// a loud red failure (T-340): expected, user-initiated denials (Deny &
/// simplify, today) that the user already understands. The reusable extension
/// point — add an id here at the moment you know its error is non-alarming.
final _quietErrorToolUses = <String>{};
/// Read-only views for the conversation view.
Set<String> get promptedToolUseIds => _promptedToolUses;
Map<String, bool> get toolUseOutcomes => _toolUseOutcome;
Set<String> get quietErrorToolUseIds => _quietErrorToolUses;
/// Whether a turn is in flight (between a send and claude's `result`). Drives
/// the composer's Stop affordance.
@@ -542,7 +555,10 @@ class StreamJsonSession {
final idx = _queue.indexWhere((p) => p.promptId == promptId);
if (idx < 0) return; // unknown / already resolved
final prompt = _queue.removeAt(idx);
if (prompt.toolUseId.isNotEmpty) _toolUseOutcome[prompt.toolUseId] = decision is AllowTool;
if (prompt.toolUseId.isNotEmpty) {
_toolUseOutcome[prompt.toolUseId] = decision is AllowTool;
if (decision is DenyTool && decision.quiet) _quietErrorToolUses.add(prompt.toolUseId);
}
_proc.writeLine(jsonEncode({
'type': 'control_response',
'response': {'subtype': 'success', 'request_id': promptId, 'response': decision.toJson()},
+53
View File
@@ -0,0 +1,53 @@
/// Claude's working task list, modelled from the conversation for the docked
/// task view (T-308).
///
/// Claude tracks tasks with the `TodoWrite` tool, which **replaces the whole
/// list** on each call — so the current state is simply the todos of the most
/// recent `TodoWrite`. This is a latest-wins snapshot, not an append log; older
/// `TodoWrite` calls are superseded.
library;
import 'package:clide/builtin/claude/src/transcript_reader.dart';
enum TaskStatus { pending, inProgress, completed }
class TaskItem {
const TaskItem({required this.text, required this.status});
final String text;
final TaskStatus status;
@override
bool operator ==(Object other) => other is TaskItem && other.text == text && other.status == status;
@override
int get hashCode => Object.hash(text, status);
}
/// The current task list — the todos of the most recent `TodoWrite` tool call,
/// or empty if Claude hasn't written one this session.
List<TaskItem> taskListFrom(List<ConversationItem> items) {
for (var i = items.length - 1; i >= 0; i--) {
final it = items[i];
if (it is! AssistantToolUse || it.name != 'TodoWrite') continue;
final raw = it.input['todos'];
if (raw is! List) return const [];
return [
for (final t in raw)
if (t is Map)
TaskItem(
// `content` is the canonical label; `activeForm` is the present-tense
// variant TodoWrite also carries — fall back to it, then to empty.
text: (t['content'] ?? t['activeForm'] ?? '').toString(),
status: _statusFrom(t['status']),
),
];
}
return const [];
}
TaskStatus _statusFrom(Object? raw) => switch (raw) {
'in_progress' => TaskStatus.inProgress,
'completed' => TaskStatus.completed,
_ => TaskStatus.pending,
};
@@ -0,0 +1,46 @@
/// Sidebar "pick up" handling (T-327/T-339): inject a ticket's prompt into the
/// active Claude session and, on acceptance, advance the ticket to in_progress.
///
/// Kept out of `extension.dart` so it's unit-testable without dragging the whole
/// (UI-wiring) extension into instrumentation.
library;
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
import 'package:clide/kernel/kernel.dart';
/// Statuses a pick-up may advance from: a not-yet-started ticket. Picking up a
/// ticket that's already `in_progress`/`review`/`done`/`cancelled` injects the
/// prompt but leaves the status alone, so a re-pick-up never drags it backwards
/// or reopens it (T-339).
const kPickUpStartableStatuses = {'backlog', 'ready'};
/// Inject a picked-up ticket's prompt into the active session (the `primary`
/// lead, else the first visible one) and, on acceptance from a not-yet-started
/// ticket, advance it to `in_progress` and publish a `changed` so the sidebar
/// refreshes (T-327/T-339). Returns whether a live session accepted the prompt.
///
/// With no live session there's no injection and no state change — a quiet
/// no-op.
Future<bool> applyTicketPickUp(
Map<String, Object?> data, {
required ClaudeSessionOrchestrator? orchestrator,
required DaemonClient ipc,
required MessageBus messages,
}) async {
final prompt = data['prompt'] as String?;
if (prompt == null || prompt.isEmpty) return false;
final target = orchestrator?.byId('primary') ?? orchestrator?.visibleSessions.firstOrNull;
if (target == null) return false; // no live session → quiet no-op, no state change
orchestrator!.injectMessage(target.id, prompt);
final id = data['id'] as String?;
final status = data['status'] as String?;
if (id != null && id.isNotEmpty && kPickUpStartableStatuses.contains(status)) {
final resp = await ipc.request('pql.tickets.status', args: {
'ids': [id],
'status': 'in_progress',
});
if (resp.ok) messages.publish('builtin.tickets', 'changed', {'id': id});
}
return true;
}
+38 -7
View File
@@ -39,7 +39,7 @@ import 'dart:isolate';
/// Discriminated union of conversation items the reader can emit.
sealed class ConversationItem {
const ConversationItem({required this.uuid, required this.timestamp, required this.isSidechain, this.parentUuid});
const ConversationItem({required this.uuid, required this.timestamp, required this.isSidechain, this.parentUuid, this.parentToolUseId});
final String uuid;
final DateTime timestamp;
@@ -50,6 +50,14 @@ sealed class ConversationItem {
/// branches off the assistant message that issued its spawning Agent/Task
/// tool-use, so this links the prompt to the right Agent card (T-263).
final String? parentUuid;
/// The `parent_tool_use_id` from the stream-json wire (T-338): the tool-use
/// id of the Agent/Task call that spawned this sub-agent message. Stream-json
/// tags every sidechain item with it — the transcript JSONL instead uses
/// [isSidechain] + [parentUuid]. When present it routes the item straight to
/// its Agent card by tool-use id, no uuid-chain walk needed, and on its own
/// marks the item as a sidechain message.
final String? parentToolUseId;
}
/// A user-typed message (plain text, possibly multi-part).
@@ -59,6 +67,7 @@ final class UserMessage extends ConversationItem {
required super.timestamp,
required super.isSidechain,
super.parentUuid,
super.parentToolUseId,
required this.text,
this.injected = false,
});
@@ -82,6 +91,7 @@ final class ToolResultMessage extends ConversationItem {
required super.timestamp,
required super.isSidechain,
super.parentUuid,
super.parentToolUseId,
required this.toolUseId,
required this.content,
required this.isError,
@@ -102,6 +112,7 @@ final class AssistantTextMessage extends ConversationItem {
required super.timestamp,
required super.isSidechain,
super.parentUuid,
super.parentToolUseId,
required this.text,
});
@@ -118,6 +129,7 @@ final class AssistantThinkingMessage extends ConversationItem {
required super.timestamp,
required super.isSidechain,
super.parentUuid,
super.parentToolUseId,
required this.thinking,
});
@@ -134,6 +146,7 @@ final class AssistantToolUse extends ConversationItem {
required super.timestamp,
required super.isSidechain,
super.parentUuid,
super.parentToolUseId,
required this.toolUseId,
required this.name,
required this.input,
@@ -555,9 +568,14 @@ void _parseLineInto(String line, List<ConversationItem> out, List<String> warnin
if (_skipTypes.contains(type)) return;
final uuid = envelope['uuid'] as String? ?? '';
final isSidechain = envelope['isSidechain'] as bool? ?? false;
final rawParent = envelope['parentUuid'] as String?;
final parentUuid = (rawParent != null && rawParent.isNotEmpty) ? rawParent : null;
// Stream-json tags sub-agent messages with `parent_tool_use_id` (the spawning
// Agent/Task tool-use), not the transcript's isSidechain/parentUuid (T-338).
// Treat its presence as a sidechain marker so the fold + de-emphasis kick in.
final rawParentTool = envelope['parent_tool_use_id'] as String?;
final parentToolUseId = (rawParentTool != null && rawParentTool.isNotEmpty) ? rawParentTool : null;
final isSidechain = (envelope['isSidechain'] as bool? ?? false) || parentToolUseId != null;
DateTime timestamp;
try {
@@ -568,9 +586,9 @@ void _parseLineInto(String line, List<ConversationItem> out, List<String> warnin
switch (type) {
case 'user':
_parseUserInto(envelope, uuid, timestamp, isSidechain, parentUuid, out);
_parseUserInto(envelope, uuid, timestamp, isSidechain, parentUuid, parentToolUseId, out);
case 'assistant':
_parseAssistantInto(envelope, uuid, timestamp, isSidechain, parentUuid, out);
_parseAssistantInto(envelope, uuid, timestamp, isSidechain, parentUuid, parentToolUseId, out);
_extractAssistantStatus(envelope, status);
default:
break; // unknown type — degrade gracefully
@@ -596,6 +614,7 @@ void _parseUserInto(
DateTime timestamp,
bool isSidechain,
String? parentUuid,
String? parentToolUseId,
List<ConversationItem> out,
) {
final message = envelope['message'] as Map?;
@@ -609,7 +628,14 @@ void _parseUserInto(
if (content is String) {
if (content.isNotEmpty) {
out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, text: content, injected: injected));
out.add(UserMessage(
uuid: uuid,
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
parentToolUseId: parentToolUseId,
text: content,
injected: injected));
}
return;
}
@@ -629,6 +655,7 @@ void _parseUserInto(
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
parentToolUseId: parentToolUseId,
toolUseId: item['tool_use_id'] as String? ?? '',
content: rawContent is String ? rawContent : jsonEncode(rawContent),
isError: item['is_error'] as bool? ?? false,
@@ -648,6 +675,7 @@ void _parseAssistantInto(
DateTime timestamp,
bool isSidechain,
String? parentUuid,
String? parentToolUseId,
List<ConversationItem> out,
) {
final message = envelope['message'] as Map?;
@@ -661,12 +689,14 @@ void _parseAssistantInto(
case 'text':
final text = item['text'] as String? ?? '';
if (text.isNotEmpty) {
out.add(AssistantTextMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, text: text));
out.add(AssistantTextMessage(
uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, parentToolUseId: parentToolUseId, text: text));
}
case 'thinking':
final thinking = item['thinking'] as String? ?? '';
if (thinking.isNotEmpty) {
out.add(AssistantThinkingMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, thinking: thinking));
out.add(AssistantThinkingMessage(
uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, parentToolUseId: parentToolUseId, thinking: thinking));
}
case 'tool_use':
final rawInput = item['input'];
@@ -675,6 +705,7 @@ void _parseAssistantInto(
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
parentToolUseId: parentToolUseId,
toolUseId: item['id'] as String? ?? '',
name: item['name'] as String? ?? '',
input: rawInput is Map ? rawInput.cast<String, dynamic>() : <String, dynamic>{},
@@ -23,6 +23,7 @@ class _DecisionsViewState extends State<DecisionsView> {
StreamSubscription<Message>? _focusSub;
StreamSubscription<DaemonEvent>? _fileSub;
StreamSubscription<SchedulerTick>? _schedulerSub;
StreamSubscription<ProjectOpened>? _projectSub;
bool _refreshing = false;
bool _pendingRefresh = false;
@@ -37,6 +38,12 @@ class _DecisionsViewState extends State<DecisionsView> {
.where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && _isDecisionPath(e.data['path'] as String? ?? ''))
.listen((_) => _refresh());
_schedulerSub = kernel.events.on<SchedulerTick>().where((e) => e.tier == SchedulerTier.oneMinute).listen((_) => _refresh());
// The first load can fire before the project's workspace is wired into
// the daemon (the boot workDir is the launch CWD, not the repo), so pql
// runs against the wrong/old DB and the list errors. Re-fetch once the
// workspace is actually open — ProjectOpened fires after the IPC server
// swaps to the project workRoot. (T-352)
_projectSub = kernel.events.on<ProjectOpened>().listen((_) => _refresh());
}
if (!_loading || _decisions.isNotEmpty) return;
unawaited(_load());
@@ -88,6 +95,7 @@ class _DecisionsViewState extends State<DecisionsView> {
_focusSub?.cancel();
_fileSub?.cancel();
_schedulerSub?.cancel();
_projectSub?.cancel();
super.dispose();
}
@@ -26,11 +26,13 @@ class KeybindingsUiExtension extends ClideExtension {
@override
Future<void> deactivate() async => _keymap = null;
/// Presets that ship today. VS Code / JetBrains (T-64/T-66) join here
/// once their YAMLs land.
/// Presets that ship today, each exposed as a `keymap.preset.<name>`
/// command that activates it.
static const _presets = <String, String>{
'default': 'Keymap: Default',
'vim': 'Keymap: Vim',
'vscode': 'Keymap: VS Code',
'jetbrains': 'Keymap: JetBrains',
};
@override
@@ -0,0 +1,36 @@
/// Builds the "pick up this ticket" prompt injected into Claude (T-327).
///
/// Takes a `pql.tickets.show` ticket map (the same fields the detail pane
/// renders) and renders it as markdown with a one-line lead-in telling Claude
/// to start working it.
library;
String pickUpPrompt(Map<String, Object?> ticket) {
String? field(String key) {
final v = ticket[key];
final s = v is String ? v.trim() : null;
return (s == null || s.isEmpty) ? null : s;
}
final meta = <String>[
if (field('type') != null) field('type')!,
if (field('status') != null) field('status')!,
if (field('priority') != null) field('priority')!,
if (field('parent_id') != null) 'parent ${field('parent_id')}',
if (field('decision_ref') != null) field('decision_ref')!,
if (field('assigned_to') != null) '@${field('assigned_to')}',
].join(' · ');
final buf = StringBuffer()
..writeln('Pick up and start working this ticket. Read it fully, then begin.')
..writeln()
..writeln('**${field('id') ?? '?'}${field('title') ?? ''}**');
if (meta.isNotEmpty) buf.writeln(meta);
final desc = field('description');
if (desc != null) {
buf
..writeln()
..writeln(desc);
}
return buf.toString().trimRight();
}
+171 -8
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:clide/builtin/tickets/src/pick_up_prompt.dart';
import 'package:clide/builtin/tickets/src/ticket_colors.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
@@ -20,9 +21,23 @@ class _TicketsViewState extends State<TicketsView> {
String? _focusedId;
final _focusedKey = GlobalKey();
final Set<String> _pinned = {'in_progress', 'ready', 'backlog'};
/// Type-filter chips (T-343), ordered large→small. Each maps 1:1 to a pql
/// ticket type; all on by default. An empty set never persists — toggling off
/// the last one snaps all back on, so the list is never mysteriously blank.
static const _allTypes = {'initiative', 'epic', 'story', 'task', 'bug'};
static const _typeOrder = [
('initiative', 'Initiative'),
('epic', 'Epic'),
('story', 'Story'),
('task', 'Task'),
('bug', 'Bug'),
];
final Set<String> _enabledTypes = {..._allTypes};
StreamSubscription<Message>? _focusSub;
StreamSubscription<SchedulerTick>? _schedulerSub;
StreamSubscription<Message>? _changedSub;
StreamSubscription<ProjectOpened>? _projectSub;
bool _refreshing = false;
bool _pendingRefresh = false;
@@ -43,6 +58,31 @@ class _TicketsViewState extends State<TicketsView> {
});
}
/// Single-click a chip: toggle that type in/out. Removing the last enabled
/// type resets all back on (T-343).
void _toggleType(String type) {
setState(() {
if (_enabledTypes.contains(type)) {
_enabledTypes.remove(type);
if (_enabledTypes.isEmpty) _enabledTypes.addAll(_allTypes);
} else {
_enabledTypes.add(type);
}
});
}
/// Double-click a chip: isolate (solo) that type — it on, all others off.
/// Double-clicking the already-soloed chip restores all-on (chart-legend
/// solo pattern, T-343).
void _soloType(String type) {
setState(() {
final soloed = _enabledTypes.length == 1 && _enabledTypes.contains(type);
_enabledTypes
..clear()
..addAll(soloed ? _allTypes : {type});
});
}
static String _sectionForStatus(String? status) => status ?? 'backlog';
void _onFocus(Message msg) {
@@ -72,6 +112,12 @@ class _TicketsViewState extends State<TicketsView> {
}));
});
_schedulerSub = kernel.events.on<SchedulerTick>().where((e) => e.tier == SchedulerTier.oneMinute).listen((_) => _refresh());
// The first load can fire before the project's workspace is wired into
// the daemon (the boot workDir is the launch CWD, not the repo), so pql
// runs against the wrong/old DB and the list errors. Re-fetch once the
// workspace is actually open — the daemon's pql workDir is correct by
// then (ProjectOpened fires after the IPC server swaps). (T-352)
_projectSub = kernel.events.on<ProjectOpened>().listen((_) => _refresh());
}
if (!_loading || _tickets.isNotEmpty) return;
unawaited(_load());
@@ -82,6 +128,7 @@ class _TicketsViewState extends State<TicketsView> {
_focusSub?.cancel();
_changedSub?.cancel();
_schedulerSub?.cancel();
_projectSub?.cancel();
super.dispose();
}
@@ -131,12 +178,14 @@ class _TicketsViewState extends State<TicketsView> {
if (_tickets.isEmpty) return const Padding(padding: EdgeInsets.all(12), child: ClideText('No tickets.\nRun `pql ticket new` to create one.', muted: true));
final lf = _filter.toLowerCase();
final hasFilter = lf.isNotEmpty;
final filtered = hasFilter
? _tickets
.where((t) => t.id.toLowerCase().contains(lf) || t.title.toLowerCase().contains(lf) || (t.status ?? '').contains(lf) || (t.type ?? '').contains(lf))
.toList()
: _tickets;
final hasTextFilter = lf.isNotEmpty;
// All-on = "no type filter" (so a full set never hides null-type tickets).
final allTypesOn = _enabledTypes.length == _allTypes.length;
final filtering = hasTextFilter || !allTypesOn;
bool textMatch(_TicketEntry t) =>
t.id.toLowerCase().contains(lf) || t.title.toLowerCase().contains(lf) || (t.status ?? '').contains(lf) || (t.type ?? '').contains(lf);
bool typeMatch(_TicketEntry t) => allTypesOn || _enabledTypes.contains(t.type);
final filtered = _tickets.where((t) => (!hasTextFilter || textMatch(t)) && typeMatch(t)).toList();
const sections = [
('in_progress', 'IN PROGRESS'),
@@ -171,6 +220,25 @@ class _TicketsViewState extends State<TicketsView> {
),
],
),
// Per-type filter chips (T-343): large→small, all on by default.
Padding(
padding: const EdgeInsets.fromLTRB(8, 2, 8, 6),
child: Wrap(
spacing: 6,
runSpacing: 4,
children: [
for (final (type, label) in _typeOrder)
_TypeChip(
label: label,
color: typeColors.forType(type),
active: _enabledTypes.contains(type),
onToggle: () => _toggleType(type),
onSolo: () => _soloType(type),
tokens: tokens,
),
],
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@@ -182,7 +250,7 @@ class _TicketsViewState extends State<TicketsView> {
ClideAccordion(
label: label,
count: items.length,
expanded: hasFilter || _isSectionExpanded(status),
expanded: filtering || _isSectionExpanded(status),
onToggle: () => _toggle(status),
children: [
for (final t in items)
@@ -204,6 +272,64 @@ class _TicketsViewState extends State<TicketsView> {
}
}
/// A type-filter chip (T-343): a type-colored dot + label. Active = filled
/// tint + colored border; inactive = muted, no fill. Single-click toggles the
/// type; double-click isolates it (chart-legend solo). One [GestureDetector]
/// owns both so Flutter disambiguates single vs double.
class _TypeChip extends StatelessWidget {
const _TypeChip({
required this.label,
required this.color,
required this.active,
required this.onToggle,
required this.onSolo,
required this.tokens,
});
final String label;
final Color color;
final bool active;
final VoidCallback onToggle;
final VoidCallback onSolo;
final SurfaceTokens tokens;
@override
Widget build(BuildContext context) {
final dotColor = active ? color : tokens.globalTextMuted;
return Semantics(
button: true,
toggled: active,
label: '$label type filter',
child: ClideTooltip(
message: 'Click to toggle · double-click to isolate',
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onToggle,
onDoubleTap: onSolo,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: active ? color.withAlpha(0x22) : null,
border: Border.all(color: active ? color : tokens.buttonBorder),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 8, height: 8, decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle)),
const SizedBox(width: 6),
ClideText(label, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: active ? tokens.globalForeground : tokens.globalTextMuted),
],
),
),
),
),
),
);
}
}
class _TicketEntry {
const _TicketEntry({required this.id, required this.title, this.type, this.status, this.priority, this.parentId});
final String id;
@@ -248,7 +374,9 @@ class _TicketCard extends StatelessWidget {
borderRadius: BorderRadius.circular(4),
border: Border.all(color: focused ? tokens.globalFocus : (hovered ? tokens.panelActiveBorder : tokens.panelBorder), width: 1),
),
child: Column(
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Parent shown as a muted breadcrumb above; the card's own ticket
@@ -290,6 +418,11 @@ class _TicketCard extends StatelessWidget {
],
],
),
// Hover affordance (T-327): hand the full ticket to the focused
// Claude pane via the message bus.
if (hovered) Positioned(top: 0, right: 0, child: _PickUpAction(id: entry.id, tokens: tokens)),
],
),
),
),
);
@@ -303,6 +436,36 @@ class _TicketCard extends StatelessWidget {
};
}
/// The hover "pick up" run-icon (T-327): fetches the full ticket and publishes
/// it on the message bus for the focused Claude pane to inject — the sidebar
/// stays decoupled from the session orchestrator (bus-only).
class _PickUpAction extends StatelessWidget {
const _PickUpAction({required this.id, required this.tokens});
final String id;
final SurfaceTokens tokens;
@override
Widget build(BuildContext context) {
return ClideTappable(
tooltip: 'Pick up — hand this ticket to the Claude pane',
onTap: () {
final kernel = ClideKernel.of(context);
unawaited(() async {
final resp = await kernel.ipc.request('pql.tickets.show', args: {'id': id, 'withContext': true});
if (!resp.ok) return; // a missing ticket / failed fetch is a quiet no-op
// Carry the current status so the receiver can gate the in_progress
// transition without a second fetch (T-339).
kernel.messages.publish('builtin.tickets', 'pick-up', {'id': id, 'prompt': pickUpPrompt(resp.data), 'status': resp.data['status']});
}());
},
builder: (ctx, hovered, _) => Padding(
padding: const EdgeInsets.all(2),
child: ClideIcon(PhosphorIcons.byName('person-simple-run'), size: 14, color: hovered ? tokens.globalFocus : tokens.globalTextMuted),
),
);
}
}
class _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.label, required this.tokens, this.status});
final String label;
+22 -7
View File
@@ -145,14 +145,29 @@ String? _firstExisting(List<String> candidates) {
}
/// Build expanded PATH inline — must be self-contained for isolate use.
String _expandedPath() {
final base = Platform.environment['PATH'] ?? '';
if (!Platform.isMacOS) return base;
final home = Platform.environment['HOME'] ?? '';
String _expandedPath() => expandToolPath(
Platform.environment['PATH'] ?? '',
isMac: Platform.isMacOS,
isLinux: Platform.isLinux,
home: Platform.environment['HOME'],
);
/// Pure PATH-expansion logic, extracted so it's testable without touching the
/// process environment.
///
/// A desktop-launched app (macOS or Linux) inherits a minimal PATH that lacks
/// the user bin dirs where tools like `pql` install (`~/.local/bin`), so tool
/// resolution fails even though a terminal launch would find them. Re-add the
/// common user/local bin dirs — that any are missing means they're prepended,
/// so they take precedence over a stale system copy (T-347). Homebrew dirs are
/// macOS-only. On other platforms the base PATH passes through unchanged.
String expandToolPath(String base, {required bool isMac, required bool isLinux, String? home}) {
if (!isMac && !isLinux) return base;
final h = home ?? '';
final extras = <String>[
if (home.isNotEmpty) '$home/.local/bin',
'/opt/homebrew/bin',
'/opt/homebrew/sbin',
if (h.isNotEmpty) '$h/.local/bin',
if (isMac) '/opt/homebrew/bin',
if (isMac) '/opt/homebrew/sbin',
'/usr/local/bin',
];
final existing = base.split(':').toSet();
+6 -1
View File
@@ -190,6 +190,9 @@ class IpcServer {
final trimmed = line.trim();
if (trimmed.isEmpty) return;
IpcResponse response;
// Tracked across the try so a dispatch failure can be logged against the
// command that caused it, for log correlation (audit #26 / T-80).
var reqCmd = '?';
try {
final msg = IpcMessage.decode(trimmed);
if (msg is! IpcRequest) {
@@ -206,6 +209,7 @@ class IpcServer {
// streaming check sees the unwrapped command (T-129). Plain
// typed requests skip this path.
var req = msg;
reqCmd = req.cmd;
if (req.cmd == argvSentinelCmd) {
final result = unwrapArgvRequest(req);
if (result is ArgvError) {
@@ -220,6 +224,7 @@ class IpcServer {
return;
}
req = (result as ArgvParsed).request;
reqCmd = req.cmd;
}
if (_isTailSubscribe(req)) {
// Long-lived subscription branch (T-129). Send the streaming
@@ -240,7 +245,7 @@ class IpcServer {
),
);
} catch (e, st) {
log.error('ipc', 'dispatch threw', error: e, stackTrace: st);
log.error('ipc', 'dispatch threw for "$reqCmd"', error: e, stackTrace: st);
response = IpcResponse.err(
id: '',
error: IpcError(
+26
View File
@@ -162,7 +162,13 @@ class PqlClient {
return const {};
}
/// pql's exit code for a locked / unavailable planning DB (EX_UNAVAILABLE) —
/// a transient SQLite-busy condition under concurrent access (T-350).
static const int _kBusyExitCode = 69;
static const int _kMaxAttempts = 4;
Future<Object?> _run(List<String> args) async {
for (var attempt = 1; attempt <= _kMaxAttempts; attempt++) {
final ProcessResult r;
try {
r = await Process.run(
@@ -181,6 +187,15 @@ class PqlClient {
// pql 1.5+ returns exit 0 with an empty `[]` for zero matches, so any
// non-zero exit is a real error (older pql used exit 2 for empty).
if (r.exitCode != 0) {
// A transient db-busy / still-settling failure — a sidebar pane firing
// its one-shot fetch too early at startup, or contention from
// concurrent pql writes — would otherwise stick until a manual refresh.
// Retry a few times with short backoff first. Genuine errors aren't
// busy, so they still surface immediately. (T-350)
if (attempt < _kMaxAttempts && _isTransient(r.exitCode, stderr)) {
await Future<void>.delayed(Duration(milliseconds: 100 * attempt));
continue;
}
throw PqlException(
'pql ${args.first} failed',
exitCode: r.exitCode,
@@ -191,4 +206,15 @@ class PqlClient {
if (stdout.isEmpty) return null;
return jsonDecode(stdout);
}
// Unreachable: the loop returns, continues, or throws on the final attempt.
throw StateError('pql retry loop exhausted without a result');
}
/// Whether a non-zero pql exit looks like a transient db-busy / not-yet-ready
/// condition worth retrying, vs. a genuine error to surface immediately.
static bool _isTransient(int exitCode, String stderr) {
if (exitCode == _kBusyExitCode) return true;
final s = stderr.toLowerCase();
return s.contains('database is locked') || s.contains('db busy') || s.contains('database busy') || s.contains('locked');
}
}
+5 -2
View File
@@ -31,6 +31,7 @@ import 'package:clide/widgets/src/clide_status_indicator.dart';
import 'package:clide/widgets/src/clide_tappable.dart';
import 'package:clide/widgets/src/clide_text.dart';
import 'package:clide/widgets/src/icons/chevron.dart';
import 'package:clide/widgets/src/spacing.dart';
import 'package:clide/widgets/src/typography.dart';
import 'package:flutter/widgets.dart';
@@ -116,7 +117,9 @@ class _ClideCollapserCardState extends State<ClideCollapserCard> {
Expanded(
child: ClideText(
widget.collapsedSummary!,
fontSize: clideFontCaption,
// Summary one step below the label, matching ConversationCard so
// collapser + tool cards read consistently in the stream (T-344).
fontSize: clideFontMeta,
fontFamily: clideMonoFamily,
color: tokens.globalTextMuted,
maxLines: 1,
@@ -139,7 +142,7 @@ class _ClideCollapserCardState extends State<ClideCollapserCard> {
// Status — the icon hard against the right edge.
if (widget.status != null) ...[
const SizedBox(width: 8),
ClideStatusIndicator(status: widget.status!, size: 12),
ClideStatusIndicator(status: widget.status!, size: clideIconHero),
],
],
);
+37
View File
@@ -17,6 +17,7 @@ import 'package:clide/widgets/src/clide_text.dart';
import 'package:clide/widgets/src/icons/phosphor.dart';
import 'package:clide/widgets/src/typography.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
@@ -61,6 +62,41 @@ class _ClideLightboxState extends State<ClideLightbox> {
void _reset() => _tc.value = Matrix4.identity();
/// A single tap that lands on the dimmed canvas around the image dismisses,
/// matching Esc / the close button (T-309). A tap on the actual image pixels
/// (not the transparent letterbox that fills the viewer) does not — nor does a
/// drag/zoom, which the [InteractiveViewer] consumes so no tap fires.
void _onTapUp(TapUpDetails d) {
final rect = _imageRect();
if (rect != null && rect.contains(d.globalPosition)) return;
widget.onDismiss();
}
/// On-screen rect of the painted image (fit:contain, after any zoom/pan), or
/// null if there's no image to find (a non-image child → any tap dismisses).
Rect? _imageRect() {
final root = context.findRenderObject();
if (root == null) return null;
RenderImage? image;
void find(RenderObject o) {
if (image != null) return;
if (o is RenderImage) {
image = o;
return;
}
o.visitChildren(find);
}
root.visitChildren(find);
final ri = image;
final pixels = ri?.image;
if (ri == null || pixels == null) return null;
final natural = Size(pixels.width.toDouble(), pixels.height.toDouble());
final painted = applyBoxFit(BoxFit.contain, natural, ri.size).destination;
final local = Alignment.center.inscribe(painted, Offset.zero & ri.size);
return MatrixUtils.transformRect(ri.getTransformTo(null), local);
}
void _onScroll(PointerSignalEvent e) {
if (e is! PointerScrollEvent) return;
final current = _tc.value.getMaxScaleOnAxis();
@@ -101,6 +137,7 @@ class _ClideLightboxState extends State<ClideLightbox> {
onPointerSignal: _onScroll,
child: GestureDetector(
onDoubleTap: _reset,
onTapUp: _onTapUp,
child: InteractiveViewer(
transformationController: _tc,
minScale: widget.minScale,
+153 -18
View File
@@ -19,13 +19,23 @@ typedef ImageTokenBuilder = Widget Function(String path);
/// it (e.g. the OS URL handler).
typedef LinkTapCallback = void Function(String url);
/// Resolves a path-like token to an absolute path if it names a file that
/// exists in the workspace, else null (T-300). The caller owns workspace-root
/// resolution + the existence check; a null resolver — or a null return —
/// leaves the token literal, so prose like "e.g." or "2.2.0" never linkifies.
typedef FileRefResolver = String? Function(String path);
/// Open a resolved workspace file in the editor, jumping to [line] when a
/// `path:line` suffix was present (T-300).
typedef FileTapCallback = void Function(String path, int? line);
/// The interaction hooks a [ClideMarkdown] render may fire — bundled into one
/// value so the render tree threads a single object instead of a growing list
/// of optional callbacks. All optional; a null hook leaves that affordance
/// inert (the text renders, just not interactive).
@immutable
class ClideMarkdownHooks {
const ClideMarkdownHooks({this.onRecordTap, this.onImageToken, this.onLinkTap});
const ClideMarkdownHooks({this.onRecordTap, this.onImageToken, this.onLinkTap, this.resolveFileRef, this.onOpenFile});
/// Tap a governance/ticket ref (T-281, D-77, …) → open the record (T-279).
final RecordTapCallback? onRecordTap;
@@ -36,11 +46,18 @@ class ClideMarkdownHooks {
/// Open an activated http(s) link (T-253).
final LinkTapCallback? onLinkTap;
/// Confirm a path-like token names a real workspace file (T-300). Both this
/// and [onOpenFile] must be set for file refs to linkify.
final FileRefResolver? resolveFileRef;
/// Open a resolved workspace file in the editor (T-300).
final FileTapCallback? onOpenFile;
static const none = ClideMarkdownHooks();
}
class ClideMarkdown extends StatelessWidget {
const ClideMarkdown(this.source, {super.key, this.onRecordTap, this.onImageToken, this.onLinkTap});
const ClideMarkdown(this.source, {super.key, this.onRecordTap, this.onImageToken, this.onLinkTap, this.resolveFileRef, this.onOpenFile});
static const double _fontSize = 16;
static const double _lineHeight = clideLineHeight;
@@ -62,10 +79,21 @@ class ClideMarkdown extends StatelessWidget {
/// a preceding space qualifies, which is how the composer emits them.
static final _imageTokenPattern = RegExp(r'(?<![^\s])@(\S+\.(?:png|jpe?g|gif|webp|bmp))', caseSensitive: false);
/// A workspace file reference in running text (T-300): an optional `/`-rooted
/// path of slash-separated segments ending in a `name.ext`, plus an optional
/// `:line` (and ignored `:col`). The leading lookbehind keeps it from
/// starting mid-token. Group 1 is the path, group 2 the line. The existence
/// check (resolver) is the real gate — this only narrows the candidates, so
/// "version 2.2.0" (ext `0`, not a letter) and "e.g." (no such file) stay
/// literal even when they slip through.
static final _filePathPattern = RegExp(r'(?<![\w@./\-])(/?(?:[\w.\-]+/)*[\w\-][\w.\-]*\.[A-Za-z][\w]*)(?::(\d+))?(?::\d+)?');
final String source;
final RecordTapCallback? onRecordTap;
final ImageTokenBuilder? onImageToken;
final LinkTapCallback? onLinkTap;
final FileRefResolver? resolveFileRef;
final FileTapCallback? onOpenFile;
static String _unescapeHtml(String s) {
return s
@@ -82,7 +110,13 @@ class ClideMarkdown extends StatelessWidget {
final tokens = ClideTheme.of(context).surface;
final doc = md.Document(extensionSet: md.ExtensionSet.gitHubFlavored);
final nodes = doc.parseLines(source.split('\n'));
final hooks = ClideMarkdownHooks(onRecordTap: onRecordTap, onImageToken: onImageToken, onLinkTap: onLinkTap);
final hooks = ClideMarkdownHooks(
onRecordTap: onRecordTap,
onImageToken: onImageToken,
onLinkTap: onLinkTap,
resolveFileRef: resolveFileRef,
onOpenFile: onOpenFile,
);
final widgets = _buildNodes(nodes, tokens, hooks);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -331,8 +365,15 @@ class ClideMarkdown extends StatelessWidget {
],
);
case 'code':
final raw = _unescapeHtml(el.textContent);
// A backticked path (`lib/app.dart:42`) → clickable, opening in the
// editor (T-300); other inline code renders verbatim.
if (hooks.resolveFileRef != null && hooks.onOpenFile != null) {
final ref = _codeFileRef(raw, hooks.resolveFileRef!);
if (ref != null) return _fileLinkSpan(raw, ref.$1, ref.$2, tokens, hooks.onOpenFile!, mono: true);
}
return TextSpan(
text: _unescapeHtml(el.textContent),
text: raw,
style: TextStyle(fontFamily: clideMonoFamily, fontSize: clideFontMono, color: tokens.syntaxString, backgroundColor: tokens.panelBackground),
);
case 'a':
@@ -346,6 +387,13 @@ class ClideMarkdown extends StatelessWidget {
if (hooks.onLinkTap != null && href != null && _isHttpUrl(href)) {
return _urlLinkSpan(text, href, tokens, hooks.onLinkTap!);
}
// A link whose href points at an existing workspace file → open it in
// the editor (T-300).
if (hooks.resolveFileRef != null && hooks.onOpenFile != null && href != null) {
final (path, line) = _splitFileRef(href);
final abs = hooks.resolveFileRef!(path);
if (abs != null) return _fileLinkSpan(text, abs, line, tokens, hooks.onOpenFile!);
}
return TextSpan(
text: text,
style: TextStyle(color: tokens.globalFocus),
@@ -361,40 +409,89 @@ class ClideMarkdown extends StatelessWidget {
}
/// Splits plain [text] into spans: pasted-image `@<path>` tokens become inline
/// image widgets via [onImageToken] (T-236), and bare governance/ticket refs
/// (T-281, D-77, Q-5, R-2) become clickable [_recordLinkSpan]s (T-279). With
/// neither callback (or no match) the text passes through unchanged.
/// image widgets via [onImageToken] (T-236), with the prose between them run
/// through [_linkifyProse] (record + file refs). With no hooks (or no match)
/// the text passes through unchanged.
static List<InlineSpan> _linkifyText(String text, SurfaceTokens tokens, ClideMarkdownHooks hooks) {
if (text.isEmpty) return [TextSpan(text: text)];
// Pass 1: pull out image tokens, record-linkifying the prose between them.
// Pass 1: pull out image tokens, linkifying the prose between them.
if (hooks.onImageToken != null) {
final spans = <InlineSpan>[];
var last = 0;
for (final m in _imageTokenPattern.allMatches(text)) {
if (m.start > last) spans.addAll(_linkifyRecords(text.substring(last, m.start), tokens, hooks.onRecordTap));
if (m.start > last) spans.addAll(_linkifyProse(text.substring(last, m.start), tokens, hooks));
spans.add(WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Padding(padding: const EdgeInsets.symmetric(horizontal: 2), child: hooks.onImageToken!(m.group(1)!)),
));
last = m.end;
}
if (last < text.length) spans.addAll(_linkifyRecords(text.substring(last), tokens, hooks.onRecordTap));
if (last < text.length) spans.addAll(_linkifyProse(text.substring(last), tokens, hooks));
return spans.isEmpty ? [TextSpan(text: text)] : spans;
}
return _linkifyRecords(text, tokens, hooks.onRecordTap);
return _linkifyProse(text, tokens, hooks);
}
static List<InlineSpan> _linkifyRecords(String text, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
if (onRecordTap == null || text.isEmpty) return [TextSpan(text: text)];
/// Linkifies running prose: governance/ticket refs (T-279) and workspace file
/// references (T-300) both become clickable spans, plain text between passing
/// through. File refs only linkify when [ClideMarkdownHooks.resolveFileRef]
/// confirms the path exists, so non-file dotted prose stays literal. On
/// overlap the earlier match wins (records and file paths don't collide in
/// practice — one needs a `.ext`, the other forbids one).
static List<InlineSpan> _linkifyProse(String text, SurfaceTokens tokens, ClideMarkdownHooks hooks) {
if (text.isEmpty) return [TextSpan(text: text)];
final wantRecords = hooks.onRecordTap != null;
final wantFiles = hooks.resolveFileRef != null && hooks.onOpenFile != null;
if (!wantRecords && !wantFiles) return [TextSpan(text: text)];
final hits = <_LinkHit>[];
if (wantRecords) {
for (final m in _bareRecordPattern.allMatches(text)) {
hits.add(_LinkHit(m.start, m.end, _recordLinkSpan(m[0]!, tokens, hooks.onRecordTap!)));
}
}
if (wantFiles) {
for (final m in _filePathPattern.allMatches(text)) {
final abs = hooks.resolveFileRef!(m.group(1)!);
if (abs == null) continue;
final line = m.group(2) == null ? null : int.tryParse(m.group(2)!);
hits.add(_LinkHit(m.start, m.end, _fileLinkSpan(text.substring(m.start, m.end), abs, line, tokens, hooks.onOpenFile!)));
}
}
if (hits.isEmpty) return [TextSpan(text: text)];
hits.sort((a, b) => a.start.compareTo(b.start));
final spans = <InlineSpan>[];
var last = 0;
for (final m in _bareRecordPattern.allMatches(text)) {
if (m.start > last) spans.add(TextSpan(text: text.substring(last, m.start)));
spans.add(_recordLinkSpan(m[0]!, tokens, onRecordTap));
last = m.end;
for (final h in hits) {
if (h.start < last) continue; // overlapped by an earlier match
if (h.start > last) spans.add(TextSpan(text: text.substring(last, h.start)));
spans.add(h.span);
last = h.end;
}
if (last < text.length) spans.add(TextSpan(text: text.substring(last)));
return spans.isEmpty ? [TextSpan(text: text)] : spans;
return spans;
}
/// If a code span's whole content is a single workspace file ref (`path` or
/// `path:line`), returns (absPath, line); else null (T-300). Requires a
/// full-content single-token match so multi-word inline code stays verbatim.
static (String, int?)? _codeFileRef(String content, FileRefResolver resolve) {
final s = content.trim();
if (s.isEmpty || s.contains(RegExp(r'\s'))) return null;
final m = _filePathPattern.firstMatch(s);
if (m == null || m.start != 0 || m.end != s.length) return null;
final abs = resolve(m.group(1)!);
if (abs == null) return null;
return (abs, m.group(2) == null ? null : int.tryParse(m.group(2)!));
}
/// Splits a `path` or `path:line` (also `path:line:col`) href into its path
/// and optional 1-based line (T-300).
static (String, int?) _splitFileRef(String raw) {
final m = RegExp(r'^(.*?):(\d+)(?::\d+)?$').firstMatch(raw);
if (m != null) return (m.group(1)!, int.tryParse(m.group(2)!));
return (raw, null);
}
/// A clickable record-reference span: [id] rendered in the focus accent with
@@ -426,6 +523,33 @@ class ClideMarkdown extends StatelessWidget {
return u != null && (u.scheme == 'http' || u.scheme == 'https') && u.host.isNotEmpty;
}
/// A clickable workspace file-reference span (T-300): the path [display] in
/// the focus accent, underlined on hover, opening the resolved [absPath] at
/// [line] (when present) in the editor via [onOpenFile]. The [mono] flag keeps
/// backticked refs in the monospace face; prose refs use the UI face.
static InlineSpan _fileLinkSpan(String display, String absPath, int? line, SurfaceTokens tokens, FileTapCallback onOpenFile, {bool mono = false}) {
return WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: ClideTappable(
onTap: () => onOpenFile(absPath, line),
tooltip: 'Open in editor',
builder: (_, hovered, __) => Text(
display,
style: TextStyle(
color: tokens.globalFocus,
fontSize: mono ? clideFontMono : _fontSize,
height: _lineHeight,
fontFamily: mono ? clideMonoFamily : clideUiFamily,
fontFamilyFallback: mono ? null : clideUiFamilyFallback,
decoration: hovered ? TextDecoration.underline : null,
decorationColor: tokens.globalFocus,
),
),
),
);
}
/// A clickable http(s) link span (T-253): the link [text] in the focus accent,
/// underlined on hover, opening [href] via [onLinkTap]. Keyboard-activatable
/// (ClideTappable) and tooltipped with the destination.
@@ -452,3 +576,14 @@ class ClideMarkdown extends StatelessWidget {
);
}
}
/// One linkified span and the `[start, end)` slice of the prose it covers, so
/// [ClideMarkdown._linkifyProse] can merge record + file matches in order and
/// drop overlaps.
@immutable
class _LinkHit {
const _LinkHit(this.start, this.end, this.span);
final int start;
final int end;
final InlineSpan span;
}
+26 -5
View File
@@ -17,19 +17,40 @@ import 'package:flutter/widgets.dart';
enum ClideRunStatus { running, success, error }
class ClideStatusIndicator extends StatelessWidget {
class ClideStatusIndicator extends StatefulWidget {
const ClideStatusIndicator({super.key, required this.status, this.size = 14});
final ClideRunStatus status;
final double size;
@override
State<ClideStatusIndicator> createState() => _ClideStatusIndicatorState();
}
class _ClideStatusIndicatorState extends State<ClideStatusIndicator> {
/// Monotonic id bumped on every status change, folded into the child key. A
/// per-status-only key collides inside [AnimatedSwitcher]'s Stack when a
/// status re-appears (running → success → running within the cross-fade)
/// while its previous glyph is still animating out — the exiting and entering
/// children share `ValueKey('running')` and trip the duplicate-key assertion
/// (T-326). The sequence makes each appearance's key unique; a same-status
/// rebuild keeps the key, so it still doesn't re-animate.
int _seq = 0;
@override
void didUpdateWidget(ClideStatusIndicator old) {
super.didUpdateWidget(old);
if (old.status != widget.status) _seq++;
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final (Widget glyph, String label) = switch (status) {
ClideRunStatus.running => (ClideSpinner(size: size, color: tokens.globalTextMuted, key: const ValueKey('running')), 'running'),
ClideRunStatus.success => (ClideIcon(const CheckIcon(), size: size, color: tokens.statusSuccess, key: const ValueKey('success')), 'succeeded'),
ClideRunStatus.error => (ClideIcon(const CloseIcon(), size: size, color: tokens.statusError, key: const ValueKey('error')), 'failed'),
final key = ValueKey('${widget.status.name}-$_seq');
final (Widget glyph, String label) = switch (widget.status) {
ClideRunStatus.running => (ClideSpinner(size: widget.size, color: tokens.globalTextMuted, key: key), 'running'),
ClideRunStatus.success => (ClideIcon(const CheckIcon(), size: widget.size, color: tokens.statusSuccess, key: key), 'succeeded'),
ClideRunStatus.error => (ClideIcon(const CloseIcon(), size: widget.size, color: tokens.statusError, key: key), 'failed'),
};
return Semantics(
label: label,
+5
View File
@@ -85,5 +85,10 @@ const double clideIconHitTarget = 16;
/// Emphatic icon (standalone affordances, primary action glyphs).
const double clideIconEmphatic = 18;
/// Hero icon — large enough to read at a glance, e.g. the activity-card run
/// status where the spinner must be legible as motion, not a static speck
/// (T-304). Success/error glyphs share it so the card doesn't jump on settle.
const double clideIconHero = 26;
/// Standard control height (tab, button, list row).
const double clideControlHeight = 28;
+12 -3
View File
@@ -13,15 +13,24 @@ apply_standard_settings(${BINARY_NAME})
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
# Find wayland-client for xdg-decoration protocol (D-057).
# Frameless custom chrome (D-057) drives the Wayland server-decoration protocol,
# which needs wayland-client at build time. It is a HARD requirement on a Linux
# desktop build host: without it the decoration-suppression path is compiled out
# and the app silently ships the compositor's native title bar the exact thing
# D-057 removes (double title bar on KDE Plasma Wayland). Fail the configure
# loudly rather than drop a core feature (T-349).
pkg_check_modules(WAYLAND_CLIENT IMPORTED_TARGET wayland-client)
if(NOT WAYLAND_CLIENT_FOUND)
message(FATAL_ERROR
"wayland-client (Wayland client dev headers) not found, but clide's "
"frameless window chrome (D-057) requires it. Install it Fedora: "
"wayland-devel, Debian/Ubuntu: libwayland-dev and rebuild.")
endif()
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
if(WAYLAND_CLIENT_FOUND)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::WAYLAND_CLIENT)
target_compile_definitions(${BINARY_NAME} PRIVATE HAS_WAYLAND_CLIENT=1)
endif()
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}/runner")
+6
View File
@@ -113,7 +113,13 @@ static void clide_app_activate(GApplication* application) {
// D-057: frameless custom chrome.
gtk_window_set_decorated(window, FALSE);
gtk_window_set_title(window, "clide");
// Run the decoration suppression on both realize (the X11 hint) and map. The
// Wayland server-decoration request needs a live wl_surface, which GTK only
// creates on map — at realize gdk_wayland_window_get_wl_surface() is still
// null and the request bails, leaving KWin (which defaults to server-side
// decorations on Wayland) to draw its own title bar (T-351).
g_signal_connect(window, "realize", G_CALLBACK(on_window_realize), nullptr);
g_signal_connect(window, "map", G_CALLBACK(on_window_realize), nullptr);
gtk_window_set_default_size(window, 1280, 720);
+1 -1
View File
@@ -13,7 +13,7 @@ description: >-
subsystem handlers (pane, files, editor, git, pql), and the
extension framework.
publish_to: none
version: 2.2.0
version: 2.3.1
repository: https://github.com/postmeridiem/clide
# Short user-facing tagline (the welcome subtitle, web meta
# description, etc.). Baked into lib/src/build_info.g.dart by
@@ -0,0 +1,72 @@
/// Widget coverage for the docked task list (T-308): hidden when empty,
/// collapsed summary with the current in-progress item, expand/collapse, and
/// per-item status semantics.
library;
import 'package:clide/builtin/claude/src/claude_task_dock.dart';
import 'package:clide/builtin/claude/src/task_list.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
const tasks = [
TaskItem(text: 'wire the dock', status: TaskStatus.completed),
TaskItem(text: 'render the rows', status: TaskStatus.inProgress),
TaskItem(text: 'write the tests', status: TaskStatus.pending),
];
Future<void> pump(WidgetTester tester, List<TaskItem> items) async {
await tester.pumpWidget(harness(
f,
Align(alignment: Alignment.topLeft, child: SizedBox(width: 400, child: ClaudeTaskDock(tasks: items))),
));
await tester.pump();
}
testWidgets('renders nothing when there are no tasks', (tester) async {
await pump(tester, const []);
expect(find.byType(ClideText), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('collapsed: a one-line summary + the current in-progress item; rows hidden', (tester) async {
final handle = tester.ensureSemantics();
await pump(tester, tasks);
expect(find.text('3 tasks · 1 done'), findsOneWidget);
expect(find.text('render the rows'), findsOneWidget); // current in-progress in the summary
// The other rows aren't shown while collapsed.
expect(find.text('wire the dock'), findsNothing);
expect(find.text('write the tests'), findsNothing);
expect(find.bySemanticsLabel('Claude task list, 3 tasks · 1 done, collapsed'), findsOneWidget);
handle.dispose();
});
testWidgets('tapping expands to the full checklist with per-item status', (tester) async {
final handle = tester.ensureSemantics();
await pump(tester, tasks);
await tester.tap(find.bySemanticsLabel('Claude task list, 3 tasks · 1 done, collapsed'));
await tester.pump();
expect(find.text('wire the dock'), findsOneWidget);
expect(find.text('render the rows'), findsOneWidget);
expect(find.text('write the tests'), findsOneWidget);
// Per-item status announced for AT.
expect(find.bySemanticsLabel('wire the dock, done'), findsOneWidget);
expect(find.bySemanticsLabel('render the rows, in progress'), findsOneWidget);
expect(find.bySemanticsLabel('write the tests, pending'), findsOneWidget);
handle.dispose();
});
testWidgets('singular task count reads "1 task"', (tester) async {
await pump(tester, const [TaskItem(text: 'lonely', status: TaskStatus.pending)]);
expect(find.text('1 task · 0 done'), findsOneWidget);
});
}
@@ -0,0 +1,55 @@
/// Tests for the workspace file-path resolver behind clickable conversation
/// references (T-300): only real files under the repo root resolve; relative
/// tokens resolve against the root, absolute tokens must already live inside it,
/// and `..` escapes are rejected.
library;
import 'dart:io';
import 'package:clide/builtin/claude/src/conversation_view.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
late Directory root;
setUp(() async {
root = await Directory.systemTemp.createTemp('clide_wsfile_');
await File('${root.path}/lib/app.dart').create(recursive: true);
});
tearDown(() async {
if (await root.exists()) await root.delete(recursive: true);
});
test('a relative path that exists resolves to its absolute path', () {
expect(resolveWorkspaceFilePath(root.path, 'lib/app.dart'), '${root.path}/lib/app.dart');
});
test('an absolute path inside the root resolves', () {
expect(resolveWorkspaceFilePath(root.path, '${root.path}/lib/app.dart'), '${root.path}/lib/app.dart');
});
test('a nonexistent path is null', () {
expect(resolveWorkspaceFilePath(root.path, 'lib/ghost.dart'), isNull);
});
test('a directory is not a file', () {
expect(resolveWorkspaceFilePath(root.path, 'lib'), isNull);
});
test('an absolute path outside the root is rejected', () {
expect(resolveWorkspaceFilePath(root.path, '/etc/passwd'), isNull);
});
test('a `..` escape is rejected', () {
expect(resolveWorkspaceFilePath(root.path, '../escape.dart'), isNull);
});
test('a null root (no project open) is null', () {
expect(resolveWorkspaceFilePath(null, 'lib/app.dart'), isNull);
});
test('an empty token is null', () {
expect(resolveWorkspaceFilePath(root.path, ''), isNull);
});
}
@@ -149,7 +149,10 @@ void main() {
tearDown(() => f.dispose());
Future<ConversationController> pumpWith(WidgetTester tester, List<ConversationItem> items,
{Set<String> hiddenToolUseIds = const {}, Map<String, bool> toolUseOutcomes = const {}, FoldLevel foldLevel = FoldLevel.none}) async {
{Set<String> hiddenToolUseIds = const {},
Map<String, bool> toolUseOutcomes = const {},
Set<String> quietErrorToolUseIds = const {},
FoldLevel foldLevel = FoldLevel.none}) async {
tester.view.physicalSize = const Size(900, 700);
tester.view.devicePixelRatio = 1.0;
addTearDown(() {
@@ -166,7 +169,12 @@ void main() {
Builder(
builder: (ctx) => MediaQuery(
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
child: ConversationView(controller: c, hiddenToolUseIds: hiddenToolUseIds, toolUseOutcomes: toolUseOutcomes, foldLevel: foldLevel),
child: ConversationView(
controller: c,
hiddenToolUseIds: hiddenToolUseIds,
toolUseOutcomes: toolUseOutcomes,
quietErrorToolUseIds: quietErrorToolUseIds,
foldLevel: foldLevel),
),
),
));
@@ -430,6 +438,24 @@ void main() {
expect(find.text('find all the widgets'), findsOneWidget);
});
testWidgets('a stream-json prompt folds via parentToolUseId, not "you" (T-338)', (tester) async {
// The live stream-json wire flags sub-agent prompts with parent_tool_use_id
// (the Task tool-use id) and NO isSidechain/parentUuid — the prompt must
// still fold into the Agent card rather than render as a blue "you" turn.
await pumpWith(tester, [
AssistantToolUse(uuid: 'agt-msg', timestamp: _t, isSidechain: false, toolUseId: 'task1', name: 'Task', input: const {'description': 'explore'}),
UserMessage(uuid: 'sp', timestamp: _t, isSidechain: true, parentToolUseId: 'task1', text: 'find all the widgets'),
]);
expect(find.text('you'), findsNothing);
expect(find.text('agent prompt'), findsNothing);
expect(find.text('Task'), findsOneWidget);
expect(find.text('find all the widgets'), findsNothing); // folded, collapsed
await tester.tap(find.bySemanticsLabel('Task, 1 step, collapsed'));
await tester.pumpAndSettle();
expect(find.text('find all the widgets'), findsOneWidget);
});
testWidgets('an orphan sidechain prompt renders as muted "agent prompt", never "you" (T-263)', (tester) async {
// No Agent tool-use to attach to → stays standalone, but relabelled.
await pumpWith(tester, [
@@ -679,6 +705,33 @@ void main() {
expect(find.text('Bash · error'), findsOneWidget);
});
testWidgets('a quiet (user-initiated) denial folds to a muted "denied" card (T-340)', (tester) async {
await pumpWith(
tester,
[
_tool('Bash', {'command': 'rm -rf x'}),
_result('Denied — too complex\nretry simpler', isError: true),
],
quietErrorToolUseIds: {'x1'}, // the toolUseId shared by _tool/_result
);
// Labelled "denied", not the loud "error", and folded by default so the
// body (its second line) is hidden behind a collapsed summary.
expect(find.text('Bash · denied'), findsOneWidget);
expect(find.text('Bash · error'), findsNothing);
expect(find.textContaining('retry simpler'), findsNothing);
});
testWidgets('a genuine error (not in the quiet set) stays expanded red (T-340)', (tester) async {
await pumpWith(tester, [
_tool('Bash', {'command': 'rm -rf x'}),
_result('Denied — too complex\nretry simpler', isError: true),
]);
// Same content, but not flagged quiet → the normal expanded error path.
expect(find.text('Bash · error'), findsOneWidget);
expect(find.text('Bash · denied'), findsNothing);
expect(find.textContaining('retry simpler'), findsOneWidget);
});
testWidgets('result without a paired tool_use uses plain "result" label (T-168)', (tester) async {
// Orphan result (no matching tool_use in the controller).
await pumpWith(tester, [
+45
View File
@@ -230,6 +230,35 @@ void main() {
expect((decision as DenyTool).message, 'write it under docs/ instead');
});
testWidgets('permission: Deny & simplify resolves with the preformatted retry-simpler note (T-311)', (tester) async {
ToolDecision? decision;
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, d) => decision = d)));
await tester.pump();
expect(find.text('3. Deny & simplify'), findsOneWidget); // slot 3 with no remember button
await tester.tap(find.text('3. Deny & simplify'));
await tester.pump();
final msg = (decision as DenyTool).message;
expect(msg, contains('too complex'));
// The clause that keeps Claude off the permission surface.
expect(msg, contains('do not add a memory'));
expect(msg, contains('change permission settings'));
// …and the clause that tells it not to narrate the reformulation (T-328).
expect(msg, contains('Do not narrate'));
});
testWidgets('permission: Deny & simplify appends the user note rather than discarding it (T-311)', (tester) async {
ToolDecision? decision;
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, d) => decision = d)));
await tester.pump();
await tester.enterText(find.byType(EditableText), 'this is a glob, not a path');
await tester.pump();
await tester.tap(find.text('3. Deny & simplify'));
await tester.pump();
final msg = (decision as DenyTool).message;
expect(msg, contains('too complex'));
expect(msg, contains('User note: this is a glob, not a path'));
});
testWidgets('permission: a typed note rides Allow as a follow-up note', (tester) async {
ToolDecision? decision;
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, d) => decision = d)));
@@ -520,6 +549,22 @@ void main() {
expect(d, isA<DenyTool>());
});
testWidgets('no remember: 3 = Deny & simplify (T-311)', (tester) async {
ToolDecision? d;
await pumpCard(tester, permissionPrompt(), (x) => d = x);
await tester.sendKeyEvent(LogicalKeyboardKey.digit3);
await tester.pump();
expect((d as DenyTool).message, contains('too complex'));
});
testWidgets('with a remember suggestion: 4 = Deny & simplify (T-311)', (tester) async {
ToolDecision? d;
await pumpCard(tester, permissionPrompt(suggestions: sugg), (x) => d = x);
await tester.sendKeyEvent(LogicalKeyboardKey.digit4);
await tester.pump();
expect((d as DenyTool).message, contains('too complex'));
});
testWidgets('a number key selects a question option', (tester) async {
ToolDecision? d;
await pumpCard(tester, questionPrompt(), (x) => d = x);
+68
View File
@@ -0,0 +1,68 @@
/// Parser for Claude's TodoWrite task list (T-308): latest-wins, status
/// mapping, content/activeForm fallback, and graceful handling of non-TodoWrite
/// or malformed input.
library;
import 'package:clide/builtin/claude/src/task_list.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:flutter_test/flutter_test.dart';
final _t = DateTime.utc(2026);
AssistantToolUse _todo(Object todos, {String id = 'x'}) =>
AssistantToolUse(uuid: id, timestamp: _t, isSidechain: false, toolUseId: id, name: 'TodoWrite', input: {'todos': todos});
void main() {
test('parses todos with their status', () {
final tasks = taskListFrom([
_todo([
{'content': 'a', 'status': 'pending'},
{'content': 'b', 'status': 'in_progress'},
{'content': 'c', 'status': 'completed'},
])
]);
expect(tasks, const [
TaskItem(text: 'a', status: TaskStatus.pending),
TaskItem(text: 'b', status: TaskStatus.inProgress),
TaskItem(text: 'c', status: TaskStatus.completed),
]);
});
test('the latest TodoWrite wins — a snapshot, not an append log', () {
final tasks = taskListFrom([
_todo([
{'content': 'old', 'status': 'pending'}
], id: '1'),
_todo([
{'content': 'new', 'status': 'in_progress'}
], id: '2'),
]);
expect(tasks, const [TaskItem(text: 'new', status: TaskStatus.inProgress)]);
});
test('content falls back to activeForm then empty; unknown status → pending', () {
final tasks = taskListFrom([
_todo([
{'activeForm': 'doing it', 'status': 'in_progress'},
{'status': 'weird'},
])
]);
expect(tasks[0].text, 'doing it');
expect(tasks[1].text, '');
expect(tasks[1].status, TaskStatus.pending);
});
test('no TodoWrite → empty', () {
expect(taskListFrom(const []), isEmpty);
expect(
taskListFrom([
AssistantToolUse(uuid: 'b', timestamp: _t, isSidechain: false, toolUseId: 'b', name: 'Bash', input: const {'command': 'ls'})
]),
isEmpty,
);
});
test('malformed todos (not a list) → empty', () {
expect(taskListFrom([_todo('nope')]), isEmpty);
});
}
@@ -0,0 +1,104 @@
/// Tests for the sidebar pick-up handler (T-327/T-339): a live session accepts
/// the prompt and a not-yet-started ticket advances to in_progress; with no live
/// session nothing is injected and the ticket is untouched.
library;
import 'dart:async';
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
import 'package:clide/builtin/claude/src/stream_json_session.dart';
import 'package:clide/builtin/claude/src/ticket_pick_up.dart';
import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/fake_ipc.dart';
class _FakeProc implements StreamJsonProcess {
final _ctl = StreamController<String>.broadcast();
final List<String> writes = [];
@override
Stream<String> get lines => _ctl.stream;
@override
void writeLine(String line) => writes.add(line);
@override
Future<void> kill() async {}
}
void main() {
late ClaudeSessionOrchestrator orch;
late FakeDaemonClient ipc;
late MessageBus messages;
late List<Map<String, Object?>> statusCalls;
late List<Message> changed;
late StreamSubscription<Message> changedSub;
setUp(() {
orch = ClaudeSessionOrchestrator(processFactory: ({required sessionArgs, required cwd, env}) async => _FakeProc());
ipc = FakeDaemonClient(log: Logger(), events: DaemonBus());
messages = MessageBus();
statusCalls = [];
ipc.stub('pql.tickets.status', (args) async {
statusCalls.add(args);
return IpcResponse.ok(id: '', data: const {'ok': true});
});
changed = [];
changedSub = messages.subscribe(publisher: 'builtin.tickets', channel: 'changed').listen(changed.add);
});
tearDown(() async {
await changedSub.cancel();
orch.dispose();
messages.dispose();
ipc.dispose();
});
Map<String, Object?> payload({String status = 'ready'}) => {'id': 'T-9', 'prompt': 'pick this up', 'status': status};
test('accepted: a live session injects the prompt and starts the ticket (T-339)', () async {
await orch.spawn(SpawnSpec(id: 'primary', role: 'primary', sessionId: 'p-uuid', cwd: '/repo'));
final accepted = await applyTicketPickUp(payload(), orchestrator: orch, ipc: ipc, messages: messages);
await Future<void>.delayed(Duration.zero); // let the bus deliver 'changed'
expect(accepted, isTrue);
expect(statusCalls, hasLength(1));
expect(statusCalls.single['ids'], ['T-9']);
expect(statusCalls.single['status'], 'in_progress');
expect(changed.single.data['id'], 'T-9');
});
test('no live session: nothing injected, ticket untouched (T-339)', () async {
// Orchestrator has no sessions → quiet no-op.
final accepted = await applyTicketPickUp(payload(), orchestrator: orch, ipc: ipc, messages: messages);
await Future<void>.delayed(Duration.zero);
expect(accepted, isFalse);
expect(statusCalls, isEmpty);
expect(changed, isEmpty);
});
test('already started: injects but does not move the status backwards (T-339)', () async {
await orch.spawn(SpawnSpec(id: 'primary', role: 'primary', sessionId: 'p-uuid', cwd: '/repo'));
final accepted = await applyTicketPickUp(payload(status: 'in_progress'), orchestrator: orch, ipc: ipc, messages: messages);
await Future<void>.delayed(Duration.zero);
expect(accepted, isTrue); // prompt still delivered
expect(statusCalls, isEmpty); // but no transition
expect(changed, isEmpty);
});
test('a backlog ticket is also startable', () async {
await orch.spawn(SpawnSpec(id: 'primary', role: 'primary', sessionId: 'p-uuid', cwd: '/repo'));
await applyTicketPickUp(payload(status: 'backlog'), orchestrator: orch, ipc: ipc, messages: messages);
expect(statusCalls, hasLength(1));
});
test('an empty prompt is ignored entirely', () async {
await orch.spawn(SpawnSpec(id: 'primary', role: 'primary', sessionId: 'p-uuid', cwd: '/repo'));
final accepted = await applyTicketPickUp({'id': 'T-9', 'prompt': '', 'status': 'ready'}, orchestrator: orch, ipc: ipc, messages: messages);
expect(accepted, isFalse);
expect(statusCalls, isEmpty);
});
}
@@ -378,6 +378,30 @@ void main() {
expect(items[0].parentUuid, 'msg-A');
expect(items[1].parentUuid, isNull); // '' → null
});
test('stream-json parent_tool_use_id marks a sidechain message (T-338)', () {
// The stream-json wire tags sub-agent messages with parent_tool_use_id and
// NO isSidechain flag — we must treat it as a sidechain item anyway.
final raw = envelope(
type: 'user',
uuid: 'sp',
message: {'role': 'user', 'content': 'go explore the codebase'},
)..['parent_tool_use_id'] = 'toolu_task1';
final items = parseAll([raw]);
expect(items.first.isSidechain, isTrue);
expect(items.first.parentToolUseId, 'toolu_task1');
});
test('empty parent_tool_use_id normalises to null and stays main-thread (T-338)', () {
final raw = envelope(
type: 'user',
uuid: 'mt',
message: {'role': 'user', 'content': 'a normal turn'},
)..['parent_tool_use_id'] = '';
final items = parseAll([raw]);
expect(items.first.isSidechain, isFalse);
expect(items.first.parentToolUseId, isNull);
});
});
// -------------------------------------------------------------------------
@@ -546,6 +546,29 @@ void main() {
});
});
group('DecisionsView — workspace open triggers reload (T-352)', () {
testWidgets('ProjectOpened triggers _refresh', (tester) async {
// The first load can fire before the daemon's pql workDir is the repo; a
// ProjectOpened (fired after the IPC server swaps) must re-fetch.
int listCallCount = 0;
f.ipc.stub('pql.decisions.sync', (_) async => _ok(const {}));
f.ipc.stub('pql.decisions.list', (_) async {
listCallCount++;
return _ok({
'decisions': [_decision(id: 'D-$listCallCount', title: 'open $listCallCount')],
});
});
await pumpView(tester);
expect(listCallCount, 1);
f.services.events.emit(const ProjectOpened(path: '/repo'));
await pumpAsync(tester);
expect(listCallCount, greaterThanOrEqualTo(2));
});
});
group('DecisionsView — concurrent refresh guard', () {
testWidgets('second refresh while one is running sets _pendingRefresh', (tester) async {
final Completer<IpcResponse> firstListCompleter = Completer();
@@ -0,0 +1,37 @@
/// The "pick up this ticket" prompt builder (T-327): lead-in, header, meta
/// line, and description, with missing fields omitted gracefully.
library;
import 'package:clide/builtin/tickets/src/pick_up_prompt.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('renders the lead-in, header, meta line, and description', () {
final p = pickUpPrompt({
'id': 'T-327',
'title': 'Pick up icon',
'type': 'story',
'status': 'ready',
'priority': 'medium',
'parent_id': 'T-276',
'description': 'Do the thing.',
});
expect(p, startsWith('Pick up and start working this ticket'));
expect(p, contains('**T-327 — Pick up icon**'));
expect(p, contains('story · ready · medium · parent T-276'));
expect(p, contains('Do the thing.'));
});
test('omits the meta line and parent when those fields are absent', () {
final p = pickUpPrompt({'id': 'T-1', 'title': 'Bare'});
expect(p, contains('**T-1 — Bare**'));
expect(p, isNot(contains('·')));
expect(p, isNot(contains('parent')));
});
test('includes the decision ref and assignee when present', () {
final p = pickUpPrompt({'id': 'T-2', 'title': 'x', 'decision_ref': 'D-90', 'assigned_to': 'jeroen'});
expect(p, contains('D-90'));
expect(p, contains('@jeroen'));
});
}
@@ -136,4 +136,82 @@ void main() {
await pumpAsync(tester);
expect(calls, greaterThan(before));
});
testWidgets('refetches when the workspace opens (T-352)', (tester) async {
// The first load can fire before the daemon's pql workDir is the repo; a
// ProjectOpened (fired after the IPC server swaps) must re-fetch.
var calls = 0;
f.ipc.stub('pql.tickets.list', (_) async {
calls++;
return _list([_t('T-1', 'Thing', 'backlog')]);
});
await pumpView(tester);
final before = calls;
f.services.events.emit(const ProjectOpened(path: '/repo'));
await pumpAsync(tester);
expect(calls, greaterThan(before));
});
// -- Type-filter chips (T-343) ------------------------------------------
// The chips own one GestureDetector for both onTap (toggle) and onDoubleTap
// (solo), so a single tap's onTap only fires after the ~300ms double-tap
// window — hence the 350ms pumps below.
Future<void> loadTwoTypes(WidgetTester tester) async {
f.ipc.stub(
'pql.tickets.list',
(_) async => _list([
_t('T-1', 'a bug item', 'backlog', type: 'bug'),
_t('T-2', 'a task item', 'backlog', type: 'task'),
]));
await pumpView(tester);
}
testWidgets('renders a chip per type, large→small (T-343)', (tester) async {
f.ipc.stub('pql.tickets.list', (_) async => _list([_t('T-1', 'x', 'backlog')]));
await pumpView(tester);
for (final label in ['Initiative', 'Epic', 'Story', 'Task', 'Bug']) {
expect(find.text(label), findsOneWidget);
}
});
testWidgets('single-click toggles a type out, others stay (T-343)', (tester) async {
await loadTwoTypes(tester);
expect(find.text('a bug item'), findsOneWidget);
await tester.tap(find.text('Bug'));
await tester.pump(const Duration(milliseconds: 350));
expect(find.text('a bug item'), findsNothing);
expect(find.text('a task item'), findsOneWidget);
});
testWidgets('double-click isolates a type (solo) (T-343)', (tester) async {
await loadTwoTypes(tester);
await tester.tap(find.text('Bug'));
await tester.pump(const Duration(milliseconds: 50));
await tester.tap(find.text('Bug'));
await tester.pump(const Duration(milliseconds: 350));
expect(find.text('a bug item'), findsOneWidget);
expect(find.text('a task item'), findsNothing);
});
testWidgets('toggling off the last enabled type resets all on (T-343)', (tester) async {
await loadTwoTypes(tester);
// Solo Bug → task hidden.
await tester.tap(find.text('Bug'));
await tester.pump(const Duration(milliseconds: 50));
await tester.tap(find.text('Bug'));
await tester.pump(const Duration(milliseconds: 350));
expect(find.text('a task item'), findsNothing);
// Toggle the only-remaining Bug off → snaps all back on → task returns.
await tester.tap(find.text('Bug'));
await tester.pump(const Duration(milliseconds: 350));
expect(find.text('a task item'), findsOneWidget);
expect(find.text('a bug item'), findsOneWidget);
});
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1,118 @@
/// Regression tests for the VS Code (T-64) and JetBrains (T-66) keymap
/// presets: activating each via `KeymapService.setPreset` resolves a
/// representative subset of its bindings as expected. The real shipped
/// `assets/keymaps/*.yaml` files are read from disk and fed through the
/// loader, so a typo in the preset fails here.
library;
import 'dart:convert';
import 'dart:io';
import 'package:clide/kernel/src/keymap/intents.dart';
import 'package:clide/kernel/src/keymap/key_chord.dart';
import 'package:clide/kernel/src/keymap/keymap_service.dart';
import 'package:clide/kernel/src/settings.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart' show Intent;
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory appDir;
late SettingsStore settings;
setUp(() async {
appDir = await Directory.systemTemp.createTemp('clide_preset_test_');
settings = SettingsStore(appDir: appDir);
await settings.load();
});
tearDown(() async {
settings.dispose();
if (await appDir.exists()) await appDir.delete(recursive: true);
});
// Build a service whose bundle serves the real shipped preset content.
Future<KeymapService> activate(String preset) async {
final src = File('assets/keymaps/$preset.yaml').readAsStringSync();
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({'assets/keymaps/$preset.yaml': src}),
);
await svc.setPreset(preset);
return svc;
}
bool isCommand(Intent? i, String id) => i is InvokeCommandIntent && i.commandId == id;
group('vscode preset (T-64)', () {
test('setPreset activates it and the representative subset resolves', () async {
final svc = await activate('vscode');
expect(settings.get<String>(kKeymapPresetSetting), 'vscode');
// Acceptance #4: the two canonical chords.
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+p'), const {}), isA<QuickOpenIntent>());
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+shift+p'), const {}), isA<PaletteOpenIntent>());
// F1 also opens the palette in VS Code.
expect(svc.keymap!.resolve(KeyChord.parse('f1'), const {}), isA<PaletteOpenIntent>());
// Search + panel toggles.
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+shift+f'), const {}), isA<FindInFilesIntent>());
expect(isCommand(svc.keymap!.resolve(KeyChord.parse('ctrl+b'), const {}), 'sidebar.collapse'), isTrue);
expect(isCommand(svc.keymap!.resolve(KeyChord.parse('ctrl+j'), const {}), 'dock.toggle'), isTrue);
expect(isCommand(svc.keymap!.resolve(KeyChord.parse('ctrl+w'), const {}), 'editor.close'), isTrue);
});
test('ctrl+p is quick-open only while the palette is closed (when-clause)', () async {
final svc = await activate('vscode');
// palette.open → ctrl+p flips to palette.selectPrevious, not quick-open.
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+p'), const {'palette.open': true}), isA<PaletteSelectPreviousIntent>());
});
test('the Ctrl+K Ctrl+T sequence binds the theme picker', () async {
final svc = await activate('vscode');
final hit = svc.keymap!.effectiveBindings.any((b) =>
b.sequence.length == 2 &&
b.sequence[0] == KeyChord.parse('ctrl+k') &&
b.sequence[1] == KeyChord.parse('ctrl+t') &&
isCommand(b.intent, 'theme.pick'));
expect(hit, isTrue);
});
});
group('jetbrains preset (T-66)', () {
test('setPreset activates it and the representative subset resolves', () async {
final svc = await activate('jetbrains');
expect(settings.get<String>(kKeymapPresetSetting), 'jetbrains');
// Double-Shift "Search Everywhere" is not expressible (T-341); Go to
// File (Ctrl+Shift+N) is the stand-in for quick-open.
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+shift+n'), const {}), isA<QuickOpenIntent>());
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+e'), const {}), isA<QuickOpenIntent>());
// Find Action → command palette.
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+shift+a'), const {}), isA<PaletteOpenIntent>());
// Find in Path; tool windows.
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+shift+f'), const {}), isA<FindInFilesIntent>());
expect(isCommand(svc.keymap!.resolve(KeyChord.parse('alt+1'), const {}), 'sidebar.collapse'), isTrue);
expect(isCommand(svc.keymap!.resolve(KeyChord.parse('alt+f12'), const {}), 'dock.toggle'), isTrue);
});
});
}
/// In-memory AssetBundle that serves whatever the constructor map says.
AssetBundle _bundle(Map<String, String> files) => _MapBundle(files);
class _MapBundle extends CachingAssetBundle {
_MapBundle(this._files);
final Map<String, String> _files;
@override
Future<ByteData> load(String key) async {
final s = _files[key];
if (s == null) throw Exception('asset not in fake bundle: $key');
// utf8 (not codeUnits) so non-ASCII in preset comments survives — the real
// rootBundle serves utf8 bytes.
return ByteData.view(Uint8List.fromList(utf8.encode(s)).buffer);
}
}
+30
View File
@@ -47,4 +47,34 @@ void main() {
expect(v.allOk, isFalse);
});
});
group('expandToolPath (T-347)', () {
const minimal = '/usr/bin:/bin'; // a desktop-launch PATH, no ~/.local/bin
test('Linux prepends ~/.local/bin + /usr/local/bin (desktop-launch fix)', () {
final out = expandToolPath(minimal, isMac: false, isLinux: true, home: '/home/u');
expect(out, '/home/u/.local/bin:/usr/local/bin:/usr/bin:/bin');
// No homebrew dirs on Linux.
expect(out.contains('/opt/homebrew'), isFalse);
});
test('macOS also adds the homebrew dirs', () {
final out = expandToolPath(minimal, isMac: true, isLinux: false, home: '/Users/u');
expect(out.split(':'), containsAll(['/Users/u/.local/bin', '/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin']));
});
test('does not duplicate dirs already on PATH', () {
final base = '/home/u/.local/bin:/usr/local/bin:/usr/bin';
expect(expandToolPath(base, isMac: false, isLinux: true, home: '/home/u'), base);
});
test('skips ~/.local/bin when HOME is empty', () {
final out = expandToolPath(minimal, isMac: false, isLinux: true, home: '');
expect(out, '/usr/local/bin:/usr/bin:/bin');
});
test('other platforms pass PATH through unchanged', () {
expect(expandToolPath(minimal, isMac: false, isLinux: false, home: '/home/u'), minimal);
});
});
}
+39
View File
@@ -150,4 +150,43 @@ void main() {
}
});
});
group('PqlClient — transient retry (T-350)', () {
late Directory tmp;
setUp(() async => tmp = await Directory.systemTemp.createTemp('clide_fakepql_'));
tearDown(() async {
if (await tmp.exists()) await tmp.delete(recursive: true);
});
// A fake `pql` whose body is [body]; a fresh `$0.n` counter file per test
// lets a script "recover" after N invocations.
Future<PqlClient> fakePql(String body) async {
final f = File('${tmp.path}/pql');
await f.writeAsString('#!/bin/sh\n$body\n');
await Process.run('chmod', ['+x', f.path]);
return PqlClient(workDir: Directory.current, toolchain: ToolchainView.resolved(ResolvedPaths(pql: f.path)));
}
test('a genuine (non-busy) error surfaces immediately', () async {
final p = await fakePql('exit 2');
await expectLater(p.files(), throwsA(isA<PqlException>().having((e) => e.exitCode, 'exitCode', 2)));
});
test('a persistent db-busy (exit 69) throws after exhausting retries', () async {
final p = await fakePql('exit 69');
await expectLater(p.files(), throwsA(isA<PqlException>().having((e) => e.exitCode, 'exitCode', 69)));
});
test('a transient db-busy (exit 69) recovers on retry', () async {
final p = await fakePql(r'c="$0.n"; n=$(cat "$c" 2>/dev/null || echo 0); n=$((n+1)); echo "$n" > "$c"; '
r'if [ "$n" -lt 3 ]; then exit 69; fi; echo "[]"');
expect(await p.files(), isEmpty); // retried through two busies to success
});
test('a "database is locked" stderr (non-69 exit) is also retried', () async {
final p = await fakePql(r'c="$0.n"; n=$(cat "$c" 2>/dev/null || echo 0); n=$((n+1)); echo "$n" > "$c"; '
r'if [ "$n" -lt 3 ]; then echo "database is locked" >&2; exit 1; fi; echo "[]"');
expect(await p.files(), isEmpty);
});
});
}
+26
View File
@@ -60,6 +60,32 @@ void main() {
expect(find.byType(InteractiveViewer), findsOneWidget);
});
testWidgets('a tap on the dimmed canvas dismisses; a tap on the image does not (T-309)', (tester) async {
final img = (await tester.runAsync(() => createTestImage(width: 100, height: 100)))!; // 1:1; real async
var dismissed = false;
await tester.pumpWidget(harness(
f,
MediaQuery(
data: const MediaQueryData(size: Size(800, 600)),
child: ClideLightbox(onDismiss: () => dismissed = true, child: RawImage(image: img, fit: BoxFit.contain)),
),
));
await tester.pumpAndSettle();
// The 94% box is ~752×564; a 1:1 image fits to 564×564 centred, leaving
// ~94px side margins. A tap in the left margin is dimmed canvas → dismiss.
final r = tester.getRect(find.byType(InteractiveViewer));
await tester.tapAt(Offset(r.left + 10, r.center.dy));
await tester.pump(const Duration(milliseconds: 350)); // past the double-tap delay
expect(dismissed, isTrue);
// A tap on the image itself does not dismiss.
dismissed = false;
await tester.tapAt(r.center);
await tester.pump(const Duration(milliseconds: 350));
expect(dismissed, isFalse);
});
testWidgets('scroll wheel zooms in and out', (tester) async {
await tester.pumpWidget(harness(f, box(() {})));
await tester.pumpAndSettle();
@@ -0,0 +1,134 @@
/// Tests for ClideMarkdown workspace file references (T-300): paths that the
/// resolver confirms exist become clickable and open in the editor; everything
/// else stays literal. The resolver is stubbed so no real filesystem is touched.
library;
import 'package:clide/widgets/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
// Resolves only the one known repo path; everything else is "not a file".
String? resolve(String p) => p == 'lib/app.dart' ? '/repo/lib/app.dart' : null;
ClideMarkdown md(String src, {void Function(String, int?)? onOpen}) => ClideMarkdown(
src,
resolveFileRef: resolve,
onOpenFile: onOpen ?? (_, __) {},
);
testWidgets('a bare repo path in prose is tappable and opens with no line', (tester) async {
String? path;
int? line = -1;
await tester.pumpWidget(harness(
f,
md('see lib/app.dart for the entry point', onOpen: (p, l) {
path = p;
line = l;
})));
await tester.pump();
await tester.tap(find.text('lib/app.dart'));
await tester.pump();
expect(path, '/repo/lib/app.dart');
expect(line, isNull);
});
testWidgets('a path:line ref opens at the line', (tester) async {
String? path;
int? line;
await tester.pumpWidget(harness(
f,
md('crash at lib/app.dart:42 today', onOpen: (p, l) {
path = p;
line = l;
})));
await tester.pump();
await tester.tap(find.text('lib/app.dart:42'));
await tester.pump();
expect(path, '/repo/lib/app.dart');
expect(line, 42);
});
testWidgets('a path:line:col ref opens at the line, ignoring the column', (tester) async {
int? line;
await tester.pumpWidget(harness(f, md('lib/app.dart:42:8', onOpen: (_, l) => line = l)));
await tester.pump();
await tester.tap(find.text('lib/app.dart:42:8'));
await tester.pump();
expect(line, 42);
});
testWidgets('a backticked path is clickable', (tester) async {
String? path;
int? line;
await tester.pumpWidget(harness(
f,
md('open `lib/app.dart:7`', onOpen: (p, l) {
path = p;
line = l;
})));
await tester.pump();
await tester.tap(find.text('lib/app.dart:7'));
await tester.pump();
expect(path, '/repo/lib/app.dart');
expect(line, 7);
});
testWidgets('a markdown [text](path) link opens the resolved href file', (tester) async {
String? path;
await tester.pumpWidget(harness(f, md('[the app](lib/app.dart)', onOpen: (p, _) => path = p)));
await tester.pump();
await tester.tap(find.text('the app'));
await tester.pump();
expect(path, '/repo/lib/app.dart');
});
testWidgets('a path that does not resolve stays literal (no link, no fire)', (tester) async {
var calls = 0;
await tester.pumpWidget(harness(f, md('nothing at lib/ghost.dart here', onOpen: (_, __) => calls++)));
await tester.pumpAndSettle();
// Rendered as plain text — tapping it does nothing.
await tester.tap(find.textContaining('lib/ghost.dart'), warnIfMissed: false);
await tester.pump();
expect(calls, 0);
});
testWidgets('dotted prose (a version number) does not linkify', (tester) async {
var calls = 0;
await tester.pumpWidget(harness(f, md('shipped version 2.2.0 today', onOpen: (_, __) => calls++)));
await tester.pumpAndSettle();
await tester.tap(find.textContaining('2.2.0'), warnIfMissed: false);
await tester.pump();
expect(calls, 0);
});
testWidgets('with no file hooks, a path renders inert (no crash)', (tester) async {
await tester.pumpWidget(harness(f, const ClideMarkdown('see lib/app.dart here')));
await tester.pumpAndSettle();
expect(find.textContaining('lib/app.dart'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('a backticked non-path code span stays verbatim (not linkified)', (tester) async {
await tester.pumpWidget(harness(f, md('run `flutter test` now')));
await tester.pumpAndSettle();
// A linkified ref renders as its own tappable Text; an inert code span is a
// styled run inside the paragraph, so it is not a standalone Text widget.
expect(find.text('flutter test'), findsNothing);
expect(tester.takeException(), isNull);
});
}
@@ -57,5 +57,37 @@ void main() {
await tester.pumpAndSettle();
expect(iconWith(CloseIcon), findsOneWidget);
});
testWidgets('rapid status flips within the cross-fade do not collide (T-326)', (tester) async {
// Real animations (no disableAnimations) so the 200ms cross-fade overlaps
// exiting and entering glyphs — the condition that tripped a duplicate
// 'running' key. Pre-fix this threw "Duplicate keys found".
var status = ClideRunStatus.running;
late StateSetter set;
await tester.pumpWidget(harness(
f,
Center(
child: StatefulBuilder(builder: (ctx, s) {
set = s;
return ClideStatusIndicator(status: status);
}),
),
));
void flip(ClideRunStatus next) => set(() => status = next);
flip(ClideRunStatus.success);
await tester.pump(const Duration(milliseconds: 40));
flip(ClideRunStatus.running); // a 2nd 'running' while the 1st is still exiting
await tester.pump(const Duration(milliseconds: 40));
flip(ClideRunStatus.error);
await tester.pump(const Duration(milliseconds: 40));
flip(ClideRunStatus.success); // land on a static glyph
await tester.pump(const Duration(milliseconds: 40));
expect(tester.takeException(), isNull);
// Unmount so any in-flight spinner controller disposes (no pending timers).
await tester.pumpWidget(const SizedBox());
});
});
}