Compare commits
@@ -55,7 +55,7 @@ Entries should be short imperative phrases that describe user-facing impact —
|
|||||||
|
|
||||||
### Be concise — this is the rule, not a suggestion
|
### 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:
|
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.
|
- 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.
|
- 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)
|
## 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.
|
- **Never** `--no-verify`. If a pre-commit hook fails, fix the underlying issue and create a new commit.
|
||||||
|
|||||||
@@ -4,9 +4,54 @@
|
|||||||
# Install: `make hooks` (points git core.hooksPath at .githooks/).
|
# Install: `make hooks` (points git core.hooksPath at .githooks/).
|
||||||
# Bypass: never. If this runs slowly, fix the slow test; don't reach
|
# Bypass: never. If this runs slowly, fix the slow test; don't reach
|
||||||
# for --no-verify (git-commit skill forbids it).
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
cd "$(git rev-parse --show-toplevel)"
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
echo "==> pre-push: make push-check"
|
z40=0000000000000000000000000000000000000000
|
||||||
make push-check
|
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -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', '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', '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 ('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);
|
||||||
|
|||||||
@@ -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 ('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 ('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 ('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 running→ClideSpinner, success→check, error→cross at the same size.
|
||||||
|
- Call sites: holder_card.dart:117 and :199 pass `size: 12` — the small value the user is seeing.
|
||||||
|
|
||||||
|
**Direction (settle in review)**
|
||||||
|
- Bump the spinner size on the activity cards (the `size: 12` call sites, and/or the indicator default) to something clearly legible — pull a concrete value from the ui-design control-geometry tokens rather than a magic number.
|
||||||
|
- Keep the running spinner, success check, and error cross visually balanced at the new size (they share `size`), so the card doesn''t jump when the state settles.
|
||||||
|
- Check the other ClideSpinner/StatusIndicator consumers (status surfaces) so the bump doesn''t bloat unrelated spots — may warrant sizing the cards explicitly rather than changing the shared default.
|
||||||
|
|
||||||
|
**Acceptance**
|
||||||
|
- The in-progress spinner on conversation activity cards is comfortably distinguishable as a spinning indicator; success/error glyphs stay aligned at the same footprint.', 'The logo-mark spinner shown on in-progress activity/holder cards in the Claude conversation is too small to read as a spinner — it reads as a static speck. Enlarge it so the running state is legible at a glance.
|
||||||
|
|
||||||
|
**Where**
|
||||||
|
- `ClideSpinner` (lib/widgets/src/clide_spinner.dart) — defaults to size 14; renders the logo SVG at width/height = size.
|
||||||
|
- `ClideStatusIndicator` (lib/widgets/src/clide_status_indicator.dart) — default size 14; maps running→ClideSpinner, success→check, error→cross at the same size.
|
||||||
|
- Call sites: holder_card.dart:117 and :199 pass `size: 12` — the small value the user is seeing.
|
||||||
|
|
||||||
|
**Direction (settle in review)**
|
||||||
|
- Bump the spinner size on the activity cards (the `size: 12` call sites, and/or the indicator default) to something clearly legible — pull a concrete value from the ui-design control-geometry tokens rather than a magic number.
|
||||||
|
- Keep the running spinner, success check, and error cross visually balanced at the new size (they share `size`), so the card doesn''t jump when the state settles.
|
||||||
|
- Check the other ClideSpinner/StatusIndicator consumers (status surfaces) so the bump doesn''t bloat unrelated spots — may warrant sizing the cards explicitly rather than changing the shared default.
|
||||||
|
|
||||||
|
**Acceptance**
|
||||||
|
- The in-progress spinner on conversation activity cards is comfortably distinguishable as a spinning indicator; success/error glyphs stay aligned at the same footprint.
|
||||||
|
|
||||||
|
**Initial trial**
|
||||||
|
- For the first cut, double the current size: the `size: 12` activity-card call sites go to `size: 24`. Trial that footprint, then settle the final value in review against the control-geometry tokens.', 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 LARGE→SMALL left to right: Initiative, Epic, Story, Task, Bug. Each maps 1:1 to its pql type (initiative/epic/story/task/bug) with its TicketTypeColors dot+border (initiative #C792EA, epic #78A0F8, story #7DD3A8, task #9AA0AA grey, bug #E87D7D). All five ON by default. Toggle/solo(double-click)/last-off-reset behaviour unchanged. Wireframe updated + approved.', 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;
|
||||||
|
|||||||
@@ -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 ('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 ('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 ('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);
|
||||||
|
|||||||
@@ -16,6 +16,119 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.3.2] — 2026-06-11
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ticket and decision sidebars reliably load on first open (real fix).** The
|
||||||
|
2.3.1 re-fetch-on-open helped only when a project is picked *after* the window
|
||||||
|
is up; with sticky-startup the project opens during boot, before the panes
|
||||||
|
mount, so they never saw the event. The underlying cause was a race: the boot
|
||||||
|
IPC-server swap (to the launch CWD) and the project-open swap (to the repo)
|
||||||
|
ran concurrently, and the late-finishing boot swap could clobber the repo
|
||||||
|
bind — leaving the daemon's pql/git/files pointed at the launch directory
|
||||||
|
(HOME) and the sidebars erroring on a stale/global pql.db. Swaps are now
|
||||||
|
serialized so the repo bind always wins. (T-352)
|
||||||
|
|
||||||
|
## [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 1–2px 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
|
## [2.2.0] — 2026-06-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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.
|
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
|
## 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.
|
[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.
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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"]
|
||||||
@@ -39,7 +39,7 @@ self:
|
|||||||
# Auto-synced from pubspec.yaml `version:` by `make gen-build-info`
|
# Auto-synced from pubspec.yaml `version:` by `make gen-build-info`
|
||||||
# (runs implicitly on every build/run/test). Don't hand-edit; bump
|
# (runs implicitly on every build/run/test). Don't hand-edit; bump
|
||||||
# pubspec instead.
|
# pubspec instead.
|
||||||
version: "2.1.0"
|
version: "2.3.2"
|
||||||
homepage: https://github.com/postmeridiem/clide
|
homepage: https://github.com/postmeridiem/clide
|
||||||
license: MIT
|
license: MIT
|
||||||
license_file: assets/LICENSE
|
license_file: assets/LICENSE
|
||||||
@@ -144,7 +144,7 @@ dependencies:
|
|||||||
purpose: >-
|
purpose: >-
|
||||||
Incremental parsing library with embedded WASM grammar engine.
|
Incremental parsing library with embedded WASM grammar engine.
|
||||||
Vendored as libtree-sitter.so (wasmtime statically linked) in
|
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.
|
through its built-in WASM store API.
|
||||||
|
|
||||||
- name: wasmtime
|
- name: wasmtime
|
||||||
@@ -232,7 +232,7 @@ dev_dependencies:
|
|||||||
|
|
||||||
- name: test
|
- name: test
|
||||||
kind: dart-package
|
kind: dart-package
|
||||||
version: "1.30.0"
|
version: "1.31.0"
|
||||||
homepage: https://pub.dev/packages/test
|
homepage: https://pub.dev/packages/test
|
||||||
license: BSD-3-Clause
|
license: BSD-3-Clause
|
||||||
purpose: >-
|
purpose: >-
|
||||||
|
|||||||
@@ -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
|
# serial-tagged tests (concurrency-vulnerable — run in their own --concurrency=1
|
||||||
# pass below). See dart_test.yaml + T-193.
|
# pass below). See dart_test.yaml + T-193.
|
||||||
if [[ "$coverage" == 1 ]]; then
|
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)"
|
echo "==> flutter test --coverage (parallel pool; excludes pty + serial)"
|
||||||
flutter test -r "$REPORTER" --coverage --exclude-tags "pty || serial" --timeout 60s
|
flutter test -r "$REPORTER" --coverage --coverage-path "$COV_TMP/parallel.info" --exclude-tags "pty || serial" --timeout 60s
|
||||||
cp coverage/lcov.info coverage/lcov.parallel.info
|
|
||||||
echo "==> flutter test --coverage (serial-tagged; --concurrency=1)"
|
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)"
|
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
|
mkdir -p coverage
|
||||||
mv coverage/lcov.merged.info coverage/lcov.info
|
python3 ci/merge_lcov.py "$COV_TMP/parallel.info" "$COV_TMP/serial.info" > "coverage/.lcov.$$.info"
|
||||||
rm -f coverage/lcov.parallel.info
|
mv -f "coverage/.lcov.$$.info" coverage/lcov.info
|
||||||
else
|
else
|
||||||
echo "==> flutter test (dev; parallel pool, excludes pty + serial)"
|
echo "==> flutter test (dev; parallel pool, excludes pty + serial)"
|
||||||
flutter test -r "$REPORTER" --exclude-tags "pty || serial" --concurrency=12 --timeout 60s
|
flutter test -r "$REPORTER" --exclude-tags "pty || serial" --concurrency=12 --timeout 60s
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ci/test_core.sh — run the Flutter-free core Dart tests.
|
# ci/test_core.sh — run the Flutter-free core Dart tests.
|
||||||
#
|
#
|
||||||
# Covers `test/` at the repo root (IPC, daemon, PTY). Wraps `dart test`
|
# Covers `test/` at the repo root (IPC, daemon, PTY, git, panes, files,
|
||||||
# in a hard timeout + process-group kill so a hanging test (typically
|
# editor, pql). Each pass runs under `dart test --timeout` so a hanging
|
||||||
# one holding a native fd open) can't wedge CI or pre-push.
|
# 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
|
# 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
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -20,32 +24,21 @@ if ! command -v dart >/dev/null; then
|
|||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Hard timeout (seconds). The PTY tests should finish in <5s; IPC/daemon
|
# Per-test hard timeout. The PTY tests should finish in <5s; IPC/daemon
|
||||||
# tests are faster still. 120s is generous for CI warmup, tiny for a
|
# tests are faster still. 60s is generous for CI warmup, tiny for a hang.
|
||||||
# hang.
|
# Matches ci/test.sh's --timeout 60s.
|
||||||
TIMEOUT_SECONDS=${TIMEOUT_SECONDS:-120}
|
TEST_TIMEOUT="${TEST_TIMEOUT:-60s}"
|
||||||
|
|
||||||
# failures-only: print failing tests + a final count, not one line per test.
|
# failures-only: print failing tests + a final count, not one line per test.
|
||||||
# Override with TEST_REPORTER=expanded when debugging. (T-242)
|
# Override with TEST_REPORTER=expanded when debugging. (T-242)
|
||||||
REPORTER="${TEST_REPORTER:-failures-only}"
|
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"
|
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() {
|
run_pass() {
|
||||||
if ! timeout --kill-after=5s "${TIMEOUT_SECONDS}s" \
|
dart test -r "$REPORTER" --timeout "$TEST_TIMEOUT" "$@"
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Some core tests must not share the parallel pool:
|
# Some core tests must not share the parallel pool:
|
||||||
@@ -58,10 +51,10 @@ run_pass() {
|
|||||||
# record_id migration.)
|
# record_id migration.)
|
||||||
# Run both in one --concurrency=1 pass (matching ci/test.sh's serial handling),
|
# Run both in one --concurrency=1 pass (matching ci/test.sh's serial handling),
|
||||||
# then everything else in parallel.
|
# 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
|
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
|
run_pass --exclude-tags "pty || serial" $CORE_DIRS
|
||||||
|
|
||||||
echo "test-core: ok"
|
echo "test-core: ok"
|
||||||
|
|||||||
@@ -33,9 +33,12 @@ One OS process. The Flutter app hosts:
|
|||||||
- every subsystem handler (pane/files/editor/git/pql),
|
- every subsystem handler (pane/files/editor/git/pql),
|
||||||
- the extension manager and all built-in extensions.
|
- the extension manager and all built-in extensions.
|
||||||
|
|
||||||
`tmux` is the only external long-lived process — it owns Claude
|
The Claude pane is driven over Claude Code's stream-json stdio control
|
||||||
session persistence so panes survive app restarts (D-41). The app
|
protocol — clide spawns the `claude` child directly and renders its
|
||||||
re-attaches via `tmux new-session -A` on boot.
|
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`
|
PTYs are spawned natively from Dart. `lib/src/pty/native_pty.dart`
|
||||||
calls `posix_openpt()` + `posix_spawn()` via FFI; the child inherits
|
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
|
event stream; every UI affordance has a matching CLI verb. See D-6
|
||||||
for the subsystem/verb/event contract.
|
for the subsystem/verb/event contract.
|
||||||
|
|
||||||
> **Caveat (2026-05):** the Unix-socket server that exposes the
|
The Unix-socket server that exposes the dispatcher to a thin `clide` C
|
||||||
> dispatcher to a thin `clide` C client is currently unimplemented.
|
client (`native/clide-cli/clide.c`) is implemented in
|
||||||
> Today's working path is in-process direct dispatch. See **T-99**
|
`lib/src/ipc/server.dart`; the socket path, access control, and dispatch
|
||||||
> (IPC server implementation) and **D-68** (dual integration surface
|
model are pinned by D-70/D-71/D-72. In-process direct dispatch remains
|
||||||
> — Bash CLI primary, MCP secondary).
|
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
|
### User-facing — Flutter desktop
|
||||||
|
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ The widget is a thin shell:
|
|||||||
- Calls `bodyBuilder(active)` for the visible content
|
- Calls `bodyBuilder(active)` for the visible content
|
||||||
- Routes user gestures to controller methods or callbacks
|
- Routes user gestures to controller methods or callbacks
|
||||||
- Emits `onCloseRequested` / `onAddRequested` so the host decides
|
- Emits `onCloseRequested` / `onAddRequested` so the host decides
|
||||||
the actual lifecycle (e.g. Claude pane spawns a new tmux session,
|
the actual lifecycle (e.g. the Claude pane spawns a new stream-json
|
||||||
doesn't just append a UI tab)
|
session, doesn't just append a UI tab)
|
||||||
|
|
||||||
The host owns the controller and the payload type. The widget never
|
The host owns the controller and the payload type. The widget never
|
||||||
touches PTY, IPC, or Claude session naming.
|
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
|
controller is seeded with `[primary]` on boot; secondaries get
|
||||||
appended as the user clicks `+`. Closing a secondary triggers
|
appended as the user clicks `+`. Closing a secondary triggers
|
||||||
`pane.close` IPC and removes the entry; closing the primary is not
|
`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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 33 KiB |
@@ -8,10 +8,10 @@ on any Linux or macOS dev box without network access or shared state.
|
|||||||
|
|
||||||
| layer | location | runner | time | when |
|
| layer | location | runner | time | when |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| unit (root) | `test/` | `dart test` | ~5s | `make test` |
|
| unit (Flutter-free core) | `test/ipc/`, `test/daemon/`, `test/pty/` | `dart test` | ~5s | `make test-core` |
|
||||||
| unit + widget + golden (app) | `app/test/` | `flutter test` | ~30s | `make test` |
|
| unit + widget + golden | `test/` | `flutter test` | ~30s | `make test` |
|
||||||
| a11y contract | `app/test/a11y/` | `flutter test` | ~5s | `make test-a11y` |
|
| a11y contract | `test/a11y/` | `flutter test` | ~5s | `make test-a11y` |
|
||||||
| integration (startup gate) | `app/integration_test/` | `flutter test integration_test/` | ~60s | `make test-integration` |
|
| 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` |
|
| 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` |
|
| startup bundle smoke | `ci/smoke_bundle.sh` | xvfb-run, 5s timeout | ~30s | `make smoke-bundle` |
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Claude Code needs to *actually use* the app while building features.
|
|||||||
The pipeline:
|
The pipeline:
|
||||||
|
|
||||||
1. **Flutter builds the app to WASM.** `flutter build web --wasm` ships
|
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
|
2. **A local server serves it.** `tools/ui/serve.sh` starts
|
||||||
`http://localhost:4280` in the background with a pidfile.
|
`http://localhost:4280` in the background with a pidfile.
|
||||||
3. **Playwright drives a headless Chromium.** Instead of click-by-pixel
|
3. **Playwright drives a headless Chromium.** Instead of click-by-pixel
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'claude_banner.dart';
|
|||||||
import 'claude_composer.dart';
|
import 'claude_composer.dart';
|
||||||
import 'claude_config.dart';
|
import 'claude_config.dart';
|
||||||
import 'claude_status.dart';
|
import 'claude_status.dart';
|
||||||
|
import 'claude_task_dock.dart';
|
||||||
import 'clipboard_paste.dart';
|
import 'clipboard_paste.dart';
|
||||||
import 'activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey;
|
import 'activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey;
|
||||||
import 'conversation_controller.dart';
|
import 'conversation_controller.dart';
|
||||||
@@ -21,6 +22,7 @@ import 'session_orchestrator.dart';
|
|||||||
import 'session_picker.dart';
|
import 'session_picker.dart';
|
||||||
import 'slash_commands.dart';
|
import 'slash_commands.dart';
|
||||||
import 'stream_json_session.dart';
|
import 'stream_json_session.dart';
|
||||||
|
import 'task_list.dart';
|
||||||
import 'transcript_reader.dart';
|
import 'transcript_reader.dart';
|
||||||
|
|
||||||
/// The Claude conversation pane. Drives `claude` over the stream-json control
|
/// 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)),
|
foldLevel: foldLevelFromName(_kernel()?.settings.get<String>(kActivityFoldLevelKey)),
|
||||||
hiddenToolUseIds: _session?.promptedToolUseIds ?? const <String>{},
|
hiddenToolUseIds: _session?.promptedToolUseIds ?? const <String>{},
|
||||||
toolUseOutcomes: _session?.toolUseOutcomes ?? const <String, bool>{},
|
toolUseOutcomes: _session?.toolUseOutcomes ?? const <String, bool>{},
|
||||||
|
quietErrorToolUseIds: _session?.quietErrorToolUseIds ?? const <String>{},
|
||||||
emptyState: ClaudeBanner(
|
emptyState: ClaudeBanner(
|
||||||
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
|
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
|
||||||
workspace: _repoRoot,
|
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
|
// An open prompt takes the composer's space and hides the text
|
||||||
// input until it's answered, so interaction stays out of the
|
// input until it's answered, so interaction stays out of the
|
||||||
// conversation stream (D-78).
|
// 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(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
if (widget.collapsible) _caret(tokens),
|
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
|
// While collapsed, show a one-line gist next to the label so the card
|
||||||
// still says what it holds.
|
// still says what it holds.
|
||||||
if (_collapsed && summary != null) ...[
|
if (_collapsed && summary != null) ...[
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ library;
|
|||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:clide/builtin/claude/src/activity_cluster.dart';
|
import 'package:clide/builtin/claude/src/activity_cluster.dart';
|
||||||
import 'package:clide/builtin/claude/src/conversation_card.dart';
|
import 'package:clide/builtin/claude/src/conversation_card.dart';
|
||||||
@@ -33,6 +34,7 @@ class ConversationView extends StatefulWidget {
|
|||||||
this.emptyState,
|
this.emptyState,
|
||||||
this.hiddenToolUseIds = const <String>{},
|
this.hiddenToolUseIds = const <String>{},
|
||||||
this.toolUseOutcomes = const <String, bool>{},
|
this.toolUseOutcomes = const <String, bool>{},
|
||||||
|
this.quietErrorToolUseIds = const <String>{},
|
||||||
this.foldLevel = FoldLevel.tools,
|
this.foldLevel = FoldLevel.tools,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,6 +54,13 @@ class ConversationView extends StatefulWidget {
|
|||||||
/// border instead of being hidden (D-78).
|
/// border instead of being hidden (D-78).
|
||||||
final Map<String, bool> toolUseOutcomes;
|
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
|
/// Whether to wrap the list in its own [ClideSelectionArea]. The team
|
||||||
/// grid sets this false and wraps all tiles in one shared area so
|
/// grid sets this false and wraps all tiles in one shared area so
|
||||||
/// selection spans tiles — nesting SelectionAreas is illegal (T-140).
|
/// selection spans tiles — nesting SelectionAreas is illegal (T-140).
|
||||||
@@ -181,6 +190,13 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
for (final it in items)
|
for (final it in items)
|
||||||
if (it is AssistantToolUse && _isAgentTool(it.name)) it.uuid: it,
|
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).
|
// Envelope-level chain info (consistent across items sharing a uuid).
|
||||||
final parentByUuid = <String, String?>{};
|
final parentByUuid = <String, String?>{};
|
||||||
final sidechainByUuid = <String, bool>{};
|
final sidechainByUuid = <String, bool>{};
|
||||||
@@ -192,6 +208,13 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
AssistantToolUse? resolveOwner(ConversationItem item, AssistantToolUse? nearest) {
|
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;
|
var cur = item.uuid;
|
||||||
final seen = <String>{};
|
final seen = <String>{};
|
||||||
while (seen.add(cur)) {
|
while (seen.add(cur)) {
|
||||||
@@ -290,6 +313,7 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
tokens: tokens,
|
tokens: tokens,
|
||||||
collapseTools: true,
|
collapseTools: true,
|
||||||
toolUseOutcomes: widget.toolUseOutcomes,
|
toolUseOutcomes: widget.toolUseOutcomes,
|
||||||
|
quietErrorToolUseIds: widget.quietErrorToolUseIds,
|
||||||
toolUseById: widget.controller.toolUseById,
|
toolUseById: widget.controller.toolUseById,
|
||||||
resultByToolUseId: resultByToolUseId,
|
resultByToolUseId: resultByToolUseId,
|
||||||
promptsByToolUseId: fold.promptsByToolUseId,
|
promptsByToolUseId: fold.promptsByToolUseId,
|
||||||
@@ -300,6 +324,7 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
items: items,
|
items: items,
|
||||||
tokens: tokens,
|
tokens: tokens,
|
||||||
toolUseOutcomes: widget.toolUseOutcomes,
|
toolUseOutcomes: widget.toolUseOutcomes,
|
||||||
|
quietErrorToolUseIds: widget.quietErrorToolUseIds,
|
||||||
toolUseById: widget.controller.toolUseById,
|
toolUseById: widget.controller.toolUseById,
|
||||||
resultByToolUseId: resultByToolUseId,
|
resultByToolUseId: resultByToolUseId,
|
||||||
promptsByToolUseId: fold.promptsByToolUseId,
|
promptsByToolUseId: fold.promptsByToolUseId,
|
||||||
@@ -310,6 +335,7 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
edits: edits,
|
edits: edits,
|
||||||
tokens: tokens,
|
tokens: tokens,
|
||||||
toolUseOutcomes: widget.toolUseOutcomes,
|
toolUseOutcomes: widget.toolUseOutcomes,
|
||||||
|
quietErrorToolUseIds: widget.quietErrorToolUseIds,
|
||||||
toolUseById: widget.controller.toolUseById,
|
toolUseById: widget.controller.toolUseById,
|
||||||
resultByToolUseId: resultByToolUseId,
|
resultByToolUseId: resultByToolUseId,
|
||||||
promptsByToolUseId: fold.promptsByToolUseId,
|
promptsByToolUseId: fold.promptsByToolUseId,
|
||||||
@@ -365,6 +391,31 @@ void _openUrl(BuildContext context, String url) {
|
|||||||
unawaited(ClideKernel.of(context).os.openURL(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.
|
/// One conversation item, rendered by kind.
|
||||||
class _ConversationTurn extends StatelessWidget {
|
class _ConversationTurn extends StatelessWidget {
|
||||||
const _ConversationTurn({
|
const _ConversationTurn({
|
||||||
@@ -373,6 +424,7 @@ class _ConversationTurn extends StatelessWidget {
|
|||||||
required this.tokens,
|
required this.tokens,
|
||||||
this.collapseTools = false,
|
this.collapseTools = false,
|
||||||
this.toolUseOutcomes = const <String, bool>{},
|
this.toolUseOutcomes = const <String, bool>{},
|
||||||
|
this.quietErrorToolUseIds = const <String>{},
|
||||||
this.toolUseById = const <String, AssistantToolUse>{},
|
this.toolUseById = const <String, AssistantToolUse>{},
|
||||||
this.resultByToolUseId = const <String, ToolResultMessage>{},
|
this.resultByToolUseId = const <String, ToolResultMessage>{},
|
||||||
this.promptsByToolUseId = const <String, List<UserMessage>>{},
|
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);
|
EdgeInsetsGeometry get _childMargin => collapseTools ? const EdgeInsets.only(bottom: 14) : const EdgeInsets.only(bottom: kClideCardHeaderPadH);
|
||||||
final Map<String, bool> toolUseOutcomes;
|
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).
|
/// Index from toolUseId → AssistantToolUse, for result-card pairing (T-168).
|
||||||
final Map<String, AssistantToolUse> toolUseById;
|
final Map<String, AssistantToolUse> toolUseById;
|
||||||
|
|
||||||
@@ -443,6 +499,8 @@ class _ConversationTurn extends StatelessWidget {
|
|||||||
onRecordTap: (id) => _openRecord(context, id),
|
onRecordTap: (id) => _openRecord(context, id),
|
||||||
onImageToken: (path) => ImageThumbnail(path: path, size: 48),
|
onImageToken: (path) => ImageThumbnail(path: path, size: 48),
|
||||||
onLinkTap: (url) => _openUrl(context, url),
|
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
|
// 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',
|
label: i.isSidechain ? 'agent' : 'claude',
|
||||||
copyText: i.text,
|
copyText: i.text,
|
||||||
margin: _childMargin,
|
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(
|
AssistantThinkingMessage() => ConversationCard(
|
||||||
// Framed + muted like the context card (T-306).
|
// Framed + muted like the context card (T-306).
|
||||||
@@ -585,6 +649,7 @@ class _ConversationTurn extends StatelessWidget {
|
|||||||
item: r,
|
item: r,
|
||||||
tokens: tokens,
|
tokens: tokens,
|
||||||
toolUseOutcomes: toolUseOutcomes,
|
toolUseOutcomes: toolUseOutcomes,
|
||||||
|
quietErrorToolUseIds: quietErrorToolUseIds,
|
||||||
toolUseById: toolUseById,
|
toolUseById: toolUseById,
|
||||||
resultByToolUseId: resultByToolUseId,
|
resultByToolUseId: resultByToolUseId,
|
||||||
promptsByToolUseId: promptsByToolUseId,
|
promptsByToolUseId: promptsByToolUseId,
|
||||||
@@ -675,22 +740,28 @@ class _ConversationTurn extends StatelessWidget {
|
|||||||
// Error result: render the error message prominently (T-168). If we have
|
// 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
|
// the paired tool_use, show the tool name as a sub-label so the user can
|
||||||
// see what failed without expanding.
|
// 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) {
|
if (t.isError) {
|
||||||
|
final quiet = quietErrorToolUseIds.contains(t.toolUseId);
|
||||||
final multiline = t.content.contains('\n');
|
final multiline = t.content.contains('\n');
|
||||||
|
final errLabel = quiet ? 'denied' : label;
|
||||||
return ConversationCard(
|
return ConversationCard(
|
||||||
variant: ConversationCardVariant.bordered,
|
variant: ConversationCardVariant.bordered,
|
||||||
accent: accent,
|
accent: quiet ? tokens.globalTextMuted : accent,
|
||||||
borderColor: tokens.statusError,
|
borderColor: quiet ? tokens.panelBorder : tokens.statusError,
|
||||||
label: paired != null ? '${paired.name} · $label' : label,
|
label: paired != null ? '${paired.name} · $errLabel' : errLabel,
|
||||||
copyText: t.content,
|
copyText: t.content,
|
||||||
collapsible: multiline,
|
collapsible: quiet || multiline,
|
||||||
collapsedByDefault: false, // errors default expanded so they're visible
|
collapsedByDefault: quiet, // genuine errors stay expanded; a denial folds
|
||||||
collapsedSummary: multiline ? _firstLine(t.content) : null,
|
collapsedSummary: (quiet || multiline) ? _firstLine(t.content) : null,
|
||||||
body: ClideText(
|
body: ClideText(
|
||||||
t.content,
|
t.content,
|
||||||
fontSize: clideFontMeta,
|
fontSize: clideFontMeta,
|
||||||
fontFamily: clideMonoFamily,
|
fontFamily: clideMonoFamily,
|
||||||
color: tokens.statusError,
|
color: quiet ? tokens.globalTextMuted : tokens.statusError,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -748,6 +819,7 @@ class _ActivityCard extends StatelessWidget {
|
|||||||
required this.items,
|
required this.items,
|
||||||
required this.tokens,
|
required this.tokens,
|
||||||
required this.toolUseOutcomes,
|
required this.toolUseOutcomes,
|
||||||
|
required this.quietErrorToolUseIds,
|
||||||
required this.toolUseById,
|
required this.toolUseById,
|
||||||
required this.resultByToolUseId,
|
required this.resultByToolUseId,
|
||||||
required this.promptsByToolUseId,
|
required this.promptsByToolUseId,
|
||||||
@@ -757,6 +829,7 @@ class _ActivityCard extends StatelessWidget {
|
|||||||
final List<ConversationItem> items;
|
final List<ConversationItem> items;
|
||||||
final SurfaceTokens tokens;
|
final SurfaceTokens tokens;
|
||||||
final Map<String, bool> toolUseOutcomes;
|
final Map<String, bool> toolUseOutcomes;
|
||||||
|
final Set<String> quietErrorToolUseIds;
|
||||||
final Map<String, AssistantToolUse> toolUseById;
|
final Map<String, AssistantToolUse> toolUseById;
|
||||||
final Map<String, ToolResultMessage> resultByToolUseId;
|
final Map<String, ToolResultMessage> resultByToolUseId;
|
||||||
final Map<String, List<UserMessage>> promptsByToolUseId;
|
final Map<String, List<UserMessage>> promptsByToolUseId;
|
||||||
@@ -777,6 +850,7 @@ class _ActivityCard extends StatelessWidget {
|
|||||||
item: item,
|
item: item,
|
||||||
tokens: tokens,
|
tokens: tokens,
|
||||||
toolUseOutcomes: toolUseOutcomes,
|
toolUseOutcomes: toolUseOutcomes,
|
||||||
|
quietErrorToolUseIds: quietErrorToolUseIds,
|
||||||
toolUseById: toolUseById,
|
toolUseById: toolUseById,
|
||||||
resultByToolUseId: resultByToolUseId,
|
resultByToolUseId: resultByToolUseId,
|
||||||
promptsByToolUseId: promptsByToolUseId,
|
promptsByToolUseId: promptsByToolUseId,
|
||||||
@@ -809,6 +883,7 @@ class _EditRunCard extends StatelessWidget {
|
|||||||
required this.edits,
|
required this.edits,
|
||||||
required this.tokens,
|
required this.tokens,
|
||||||
required this.toolUseOutcomes,
|
required this.toolUseOutcomes,
|
||||||
|
required this.quietErrorToolUseIds,
|
||||||
required this.toolUseById,
|
required this.toolUseById,
|
||||||
required this.resultByToolUseId,
|
required this.resultByToolUseId,
|
||||||
required this.promptsByToolUseId,
|
required this.promptsByToolUseId,
|
||||||
@@ -818,6 +893,7 @@ class _EditRunCard extends StatelessWidget {
|
|||||||
final List<ConversationItem> edits;
|
final List<ConversationItem> edits;
|
||||||
final SurfaceTokens tokens;
|
final SurfaceTokens tokens;
|
||||||
final Map<String, bool> toolUseOutcomes;
|
final Map<String, bool> toolUseOutcomes;
|
||||||
|
final Set<String> quietErrorToolUseIds;
|
||||||
final Map<String, AssistantToolUse> toolUseById;
|
final Map<String, AssistantToolUse> toolUseById;
|
||||||
final Map<String, ToolResultMessage> resultByToolUseId;
|
final Map<String, ToolResultMessage> resultByToolUseId;
|
||||||
final Map<String, List<UserMessage>> promptsByToolUseId;
|
final Map<String, List<UserMessage>> promptsByToolUseId;
|
||||||
@@ -838,6 +914,7 @@ class _EditRunCard extends StatelessWidget {
|
|||||||
item: item,
|
item: item,
|
||||||
tokens: tokens,
|
tokens: tokens,
|
||||||
toolUseOutcomes: toolUseOutcomes,
|
toolUseOutcomes: toolUseOutcomes,
|
||||||
|
quietErrorToolUseIds: quietErrorToolUseIds,
|
||||||
toolUseById: toolUseById,
|
toolUseById: toolUseById,
|
||||||
resultByToolUseId: resultByToolUseId,
|
resultByToolUseId: resultByToolUseId,
|
||||||
promptsByToolUseId: promptsByToolUseId,
|
promptsByToolUseId: promptsByToolUseId,
|
||||||
|
|||||||
@@ -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/claude_meta_sidebar.dart';
|
||||||
import 'package:clide/builtin/claude/src/session_index.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/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/builtin/claude/src/transcript_reader.dart' show ImageMessage;
|
||||||
import 'package:clide/src/daemon/image_commands.dart' show imageShowChannel;
|
import 'package:clide/src/daemon/image_commands.dart' show imageShowChannel;
|
||||||
import 'package:clide/builtin/claude/src/team_chat_sidebar.dart' show TeamChatPane;
|
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
|
// 'image' message; we inject the matching card into the conversation the
|
||||||
// user is looking at (the primary lead, else the first visible session).
|
// user is looking at (the primary lead, else the first visible session).
|
||||||
_subs.add(ctx.messages.subscribe(channel: imageShowChannel).listen(_onImageShow));
|
_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
|
/// Close every session that doesn't belong to the newly-active workspace
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/// The interactive prompt surface for the stream-json control channel
|
/// 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).
|
/// or an `AskUserQuestion` picker (single = bare; multi = stepper + review).
|
||||||
/// Rendered in the composer zone (not inline in the conversation) so
|
/// Rendered in the composer zone (not inline in the conversation) so
|
||||||
/// interaction and conversation widgets don't mix — the pane swaps it in for
|
/// 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.
|
/// Sentinel option key for the always-present free-text "Other…" choice.
|
||||||
const _kOther = '\u0000other';
|
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 {
|
class ToolPromptCard extends StatefulWidget {
|
||||||
const ToolPromptCard({super.key, required this.prompt, required this.onResolve});
|
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 (n == 1) return _then(_permAllow);
|
||||||
if (canRemember && n == 2) return _then(() => _permAllow(remember: true));
|
if (canRemember && n == 2) return _then(() => _permAllow(remember: true));
|
||||||
if (n == (canRemember ? 3 : 2)) return _then(_permDeny);
|
if (n == (canRemember ? 3 : 2)) return _then(_permDeny);
|
||||||
|
if (n == (canRemember ? 4 : 3)) return _then(_permDenySimplify);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final qi = _currentQuestion();
|
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.'));
|
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) {
|
(Color, String, List<Widget>) _permission(SurfaceTokens tokens) {
|
||||||
final p = widget.prompt;
|
final p = widget.prompt;
|
||||||
final canRemember = p.permissionSuggestions.isNotEmpty;
|
final canRemember = p.permissionSuggestions.isNotEmpty;
|
||||||
@@ -245,6 +268,11 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
|||||||
ClideButton(label: '1. Allow', variant: ClideButtonVariant.primary, onPressed: () => _permAllow()),
|
ClideButton(label: '1. Allow', variant: ClideButtonVariant.primary, onPressed: () => _permAllow()),
|
||||||
if (canRemember) ClideButton(label: "2. Allow & don't ask again", onPressed: () => _permAllow(remember: true)),
|
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 ? '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),
|
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).
|
/// 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 {
|
final class DenyTool extends ToolDecision {
|
||||||
const DenyTool(this.message);
|
const DenyTool(this.message, {this.quiet = false});
|
||||||
final String message;
|
final String message;
|
||||||
|
final bool quiet;
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() => {'behavior': 'deny', 'message': message};
|
Map<String, dynamic> toJson() => {'behavior': 'deny', 'message': message};
|
||||||
}
|
}
|
||||||
@@ -246,9 +252,16 @@ class StreamJsonSession {
|
|||||||
/// green/red border (D-78).
|
/// green/red border (D-78).
|
||||||
final _toolUseOutcome = <String, bool>{};
|
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.
|
/// Read-only views for the conversation view.
|
||||||
Set<String> get promptedToolUseIds => _promptedToolUses;
|
Set<String> get promptedToolUseIds => _promptedToolUses;
|
||||||
Map<String, bool> get toolUseOutcomes => _toolUseOutcome;
|
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
|
/// Whether a turn is in flight (between a send and claude's `result`). Drives
|
||||||
/// the composer's Stop affordance.
|
/// the composer's Stop affordance.
|
||||||
@@ -542,7 +555,10 @@ class StreamJsonSession {
|
|||||||
final idx = _queue.indexWhere((p) => p.promptId == promptId);
|
final idx = _queue.indexWhere((p) => p.promptId == promptId);
|
||||||
if (idx < 0) return; // unknown / already resolved
|
if (idx < 0) return; // unknown / already resolved
|
||||||
final prompt = _queue.removeAt(idx);
|
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({
|
_proc.writeLine(jsonEncode({
|
||||||
'type': 'control_response',
|
'type': 'control_response',
|
||||||
'response': {'subtype': 'success', 'request_id': promptId, 'response': decision.toJson()},
|
'response': {'subtype': 'success', 'request_id': promptId, 'response': decision.toJson()},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ import 'dart:isolate';
|
|||||||
|
|
||||||
/// Discriminated union of conversation items the reader can emit.
|
/// Discriminated union of conversation items the reader can emit.
|
||||||
sealed class ConversationItem {
|
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 String uuid;
|
||||||
final DateTime timestamp;
|
final DateTime timestamp;
|
||||||
@@ -50,6 +50,14 @@ sealed class ConversationItem {
|
|||||||
/// branches off the assistant message that issued its spawning Agent/Task
|
/// 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).
|
/// tool-use, so this links the prompt to the right Agent card (T-263).
|
||||||
final String? parentUuid;
|
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).
|
/// A user-typed message (plain text, possibly multi-part).
|
||||||
@@ -59,6 +67,7 @@ final class UserMessage extends ConversationItem {
|
|||||||
required super.timestamp,
|
required super.timestamp,
|
||||||
required super.isSidechain,
|
required super.isSidechain,
|
||||||
super.parentUuid,
|
super.parentUuid,
|
||||||
|
super.parentToolUseId,
|
||||||
required this.text,
|
required this.text,
|
||||||
this.injected = false,
|
this.injected = false,
|
||||||
});
|
});
|
||||||
@@ -82,6 +91,7 @@ final class ToolResultMessage extends ConversationItem {
|
|||||||
required super.timestamp,
|
required super.timestamp,
|
||||||
required super.isSidechain,
|
required super.isSidechain,
|
||||||
super.parentUuid,
|
super.parentUuid,
|
||||||
|
super.parentToolUseId,
|
||||||
required this.toolUseId,
|
required this.toolUseId,
|
||||||
required this.content,
|
required this.content,
|
||||||
required this.isError,
|
required this.isError,
|
||||||
@@ -102,6 +112,7 @@ final class AssistantTextMessage extends ConversationItem {
|
|||||||
required super.timestamp,
|
required super.timestamp,
|
||||||
required super.isSidechain,
|
required super.isSidechain,
|
||||||
super.parentUuid,
|
super.parentUuid,
|
||||||
|
super.parentToolUseId,
|
||||||
required this.text,
|
required this.text,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,6 +129,7 @@ final class AssistantThinkingMessage extends ConversationItem {
|
|||||||
required super.timestamp,
|
required super.timestamp,
|
||||||
required super.isSidechain,
|
required super.isSidechain,
|
||||||
super.parentUuid,
|
super.parentUuid,
|
||||||
|
super.parentToolUseId,
|
||||||
required this.thinking,
|
required this.thinking,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,6 +146,7 @@ final class AssistantToolUse extends ConversationItem {
|
|||||||
required super.timestamp,
|
required super.timestamp,
|
||||||
required super.isSidechain,
|
required super.isSidechain,
|
||||||
super.parentUuid,
|
super.parentUuid,
|
||||||
|
super.parentToolUseId,
|
||||||
required this.toolUseId,
|
required this.toolUseId,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.input,
|
required this.input,
|
||||||
@@ -555,9 +568,14 @@ void _parseLineInto(String line, List<ConversationItem> out, List<String> warnin
|
|||||||
if (_skipTypes.contains(type)) return;
|
if (_skipTypes.contains(type)) return;
|
||||||
|
|
||||||
final uuid = envelope['uuid'] as String? ?? '';
|
final uuid = envelope['uuid'] as String? ?? '';
|
||||||
final isSidechain = envelope['isSidechain'] as bool? ?? false;
|
|
||||||
final rawParent = envelope['parentUuid'] as String?;
|
final rawParent = envelope['parentUuid'] as String?;
|
||||||
final parentUuid = (rawParent != null && rawParent.isNotEmpty) ? rawParent : null;
|
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;
|
DateTime timestamp;
|
||||||
try {
|
try {
|
||||||
@@ -568,9 +586,9 @@ void _parseLineInto(String line, List<ConversationItem> out, List<String> warnin
|
|||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'user':
|
case 'user':
|
||||||
_parseUserInto(envelope, uuid, timestamp, isSidechain, parentUuid, out);
|
_parseUserInto(envelope, uuid, timestamp, isSidechain, parentUuid, parentToolUseId, out);
|
||||||
case 'assistant':
|
case 'assistant':
|
||||||
_parseAssistantInto(envelope, uuid, timestamp, isSidechain, parentUuid, out);
|
_parseAssistantInto(envelope, uuid, timestamp, isSidechain, parentUuid, parentToolUseId, out);
|
||||||
_extractAssistantStatus(envelope, status);
|
_extractAssistantStatus(envelope, status);
|
||||||
default:
|
default:
|
||||||
break; // unknown type — degrade gracefully
|
break; // unknown type — degrade gracefully
|
||||||
@@ -596,6 +614,7 @@ void _parseUserInto(
|
|||||||
DateTime timestamp,
|
DateTime timestamp,
|
||||||
bool isSidechain,
|
bool isSidechain,
|
||||||
String? parentUuid,
|
String? parentUuid,
|
||||||
|
String? parentToolUseId,
|
||||||
List<ConversationItem> out,
|
List<ConversationItem> out,
|
||||||
) {
|
) {
|
||||||
final message = envelope['message'] as Map?;
|
final message = envelope['message'] as Map?;
|
||||||
@@ -609,7 +628,14 @@ void _parseUserInto(
|
|||||||
|
|
||||||
if (content is String) {
|
if (content is String) {
|
||||||
if (content.isNotEmpty) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -629,6 +655,7 @@ void _parseUserInto(
|
|||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
isSidechain: isSidechain,
|
isSidechain: isSidechain,
|
||||||
parentUuid: parentUuid,
|
parentUuid: parentUuid,
|
||||||
|
parentToolUseId: parentToolUseId,
|
||||||
toolUseId: item['tool_use_id'] as String? ?? '',
|
toolUseId: item['tool_use_id'] as String? ?? '',
|
||||||
content: rawContent is String ? rawContent : jsonEncode(rawContent),
|
content: rawContent is String ? rawContent : jsonEncode(rawContent),
|
||||||
isError: item['is_error'] as bool? ?? false,
|
isError: item['is_error'] as bool? ?? false,
|
||||||
@@ -648,6 +675,7 @@ void _parseAssistantInto(
|
|||||||
DateTime timestamp,
|
DateTime timestamp,
|
||||||
bool isSidechain,
|
bool isSidechain,
|
||||||
String? parentUuid,
|
String? parentUuid,
|
||||||
|
String? parentToolUseId,
|
||||||
List<ConversationItem> out,
|
List<ConversationItem> out,
|
||||||
) {
|
) {
|
||||||
final message = envelope['message'] as Map?;
|
final message = envelope['message'] as Map?;
|
||||||
@@ -661,12 +689,14 @@ void _parseAssistantInto(
|
|||||||
case 'text':
|
case 'text':
|
||||||
final text = item['text'] as String? ?? '';
|
final text = item['text'] as String? ?? '';
|
||||||
if (text.isNotEmpty) {
|
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':
|
case 'thinking':
|
||||||
final thinking = item['thinking'] as String? ?? '';
|
final thinking = item['thinking'] as String? ?? '';
|
||||||
if (thinking.isNotEmpty) {
|
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':
|
case 'tool_use':
|
||||||
final rawInput = item['input'];
|
final rawInput = item['input'];
|
||||||
@@ -675,6 +705,7 @@ void _parseAssistantInto(
|
|||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
isSidechain: isSidechain,
|
isSidechain: isSidechain,
|
||||||
parentUuid: parentUuid,
|
parentUuid: parentUuid,
|
||||||
|
parentToolUseId: parentToolUseId,
|
||||||
toolUseId: item['id'] as String? ?? '',
|
toolUseId: item['id'] as String? ?? '',
|
||||||
name: item['name'] as String? ?? '',
|
name: item['name'] as String? ?? '',
|
||||||
input: rawInput is Map ? rawInput.cast<String, dynamic>() : <String, dynamic>{},
|
input: rawInput is Map ? rawInput.cast<String, dynamic>() : <String, dynamic>{},
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class _DecisionsViewState extends State<DecisionsView> {
|
|||||||
StreamSubscription<Message>? _focusSub;
|
StreamSubscription<Message>? _focusSub;
|
||||||
StreamSubscription<DaemonEvent>? _fileSub;
|
StreamSubscription<DaemonEvent>? _fileSub;
|
||||||
StreamSubscription<SchedulerTick>? _schedulerSub;
|
StreamSubscription<SchedulerTick>? _schedulerSub;
|
||||||
|
StreamSubscription<ProjectOpened>? _projectSub;
|
||||||
bool _refreshing = false;
|
bool _refreshing = false;
|
||||||
bool _pendingRefresh = 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? ?? ''))
|
.where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && _isDecisionPath(e.data['path'] as String? ?? ''))
|
||||||
.listen((_) => _refresh());
|
.listen((_) => _refresh());
|
||||||
_schedulerSub = kernel.events.on<SchedulerTick>().where((e) => e.tier == SchedulerTier.oneMinute).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;
|
if (!_loading || _decisions.isNotEmpty) return;
|
||||||
unawaited(_load());
|
unawaited(_load());
|
||||||
@@ -88,6 +95,7 @@ class _DecisionsViewState extends State<DecisionsView> {
|
|||||||
_focusSub?.cancel();
|
_focusSub?.cancel();
|
||||||
_fileSub?.cancel();
|
_fileSub?.cancel();
|
||||||
_schedulerSub?.cancel();
|
_schedulerSub?.cancel();
|
||||||
|
_projectSub?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,13 @@ class KeybindingsUiExtension extends ClideExtension {
|
|||||||
@override
|
@override
|
||||||
Future<void> deactivate() async => _keymap = null;
|
Future<void> deactivate() async => _keymap = null;
|
||||||
|
|
||||||
/// Presets that ship today. VS Code / JetBrains (T-64/T-66) join here
|
/// Presets that ship today, each exposed as a `keymap.preset.<name>`
|
||||||
/// once their YAMLs land.
|
/// command that activates it.
|
||||||
static const _presets = <String, String>{
|
static const _presets = <String, String>{
|
||||||
'default': 'Keymap: Default',
|
'default': 'Keymap: Default',
|
||||||
'vim': 'Keymap: Vim',
|
'vim': 'Keymap: Vim',
|
||||||
|
'vscode': 'Keymap: VS Code',
|
||||||
|
'jetbrains': 'Keymap: JetBrains',
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@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();
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
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/builtin/tickets/src/ticket_colors.dart';
|
||||||
import 'package:clide/kernel/kernel.dart';
|
import 'package:clide/kernel/kernel.dart';
|
||||||
import 'package:clide/widgets/widgets.dart';
|
import 'package:clide/widgets/widgets.dart';
|
||||||
@@ -20,9 +21,23 @@ class _TicketsViewState extends State<TicketsView> {
|
|||||||
String? _focusedId;
|
String? _focusedId;
|
||||||
final _focusedKey = GlobalKey();
|
final _focusedKey = GlobalKey();
|
||||||
final Set<String> _pinned = {'in_progress', 'ready', 'backlog'};
|
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<Message>? _focusSub;
|
||||||
StreamSubscription<SchedulerTick>? _schedulerSub;
|
StreamSubscription<SchedulerTick>? _schedulerSub;
|
||||||
StreamSubscription<Message>? _changedSub;
|
StreamSubscription<Message>? _changedSub;
|
||||||
|
StreamSubscription<ProjectOpened>? _projectSub;
|
||||||
bool _refreshing = false;
|
bool _refreshing = false;
|
||||||
bool _pendingRefresh = 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';
|
static String _sectionForStatus(String? status) => status ?? 'backlog';
|
||||||
|
|
||||||
void _onFocus(Message msg) {
|
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());
|
_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;
|
if (!_loading || _tickets.isNotEmpty) return;
|
||||||
unawaited(_load());
|
unawaited(_load());
|
||||||
@@ -82,6 +128,7 @@ class _TicketsViewState extends State<TicketsView> {
|
|||||||
_focusSub?.cancel();
|
_focusSub?.cancel();
|
||||||
_changedSub?.cancel();
|
_changedSub?.cancel();
|
||||||
_schedulerSub?.cancel();
|
_schedulerSub?.cancel();
|
||||||
|
_projectSub?.cancel();
|
||||||
super.dispose();
|
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));
|
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 lf = _filter.toLowerCase();
|
||||||
final hasFilter = lf.isNotEmpty;
|
final hasTextFilter = lf.isNotEmpty;
|
||||||
final filtered = hasFilter
|
// All-on = "no type filter" (so a full set never hides null-type tickets).
|
||||||
? _tickets
|
final allTypesOn = _enabledTypes.length == _allTypes.length;
|
||||||
.where((t) => t.id.toLowerCase().contains(lf) || t.title.toLowerCase().contains(lf) || (t.status ?? '').contains(lf) || (t.type ?? '').contains(lf))
|
final filtering = hasTextFilter || !allTypesOn;
|
||||||
.toList()
|
bool textMatch(_TicketEntry t) =>
|
||||||
: _tickets;
|
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 = [
|
const sections = [
|
||||||
('in_progress', 'IN PROGRESS'),
|
('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(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
@@ -182,7 +250,7 @@ class _TicketsViewState extends State<TicketsView> {
|
|||||||
ClideAccordion(
|
ClideAccordion(
|
||||||
label: label,
|
label: label,
|
||||||
count: items.length,
|
count: items.length,
|
||||||
expanded: hasFilter || _isSectionExpanded(status),
|
expanded: filtering || _isSectionExpanded(status),
|
||||||
onToggle: () => _toggle(status),
|
onToggle: () => _toggle(status),
|
||||||
children: [
|
children: [
|
||||||
for (final t in items)
|
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 {
|
class _TicketEntry {
|
||||||
const _TicketEntry({required this.id, required this.title, this.type, this.status, this.priority, this.parentId});
|
const _TicketEntry({required this.id, required this.title, this.type, this.status, this.priority, this.parentId});
|
||||||
final String id;
|
final String id;
|
||||||
@@ -248,7 +374,9 @@ class _TicketCard extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
border: Border.all(color: focused ? tokens.globalFocus : (hovered ? tokens.panelActiveBorder : tokens.panelBorder), width: 1),
|
border: Border.all(color: focused ? tokens.globalFocus : (hovered ? tokens.panelActiveBorder : tokens.panelBorder), width: 1),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Parent shown as a muted breadcrumb above; the card's own ticket
|
// 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 {
|
class _StatusBadge extends StatelessWidget {
|
||||||
const _StatusBadge({required this.label, required this.tokens, this.status});
|
const _StatusBadge({required this.label, required this.tokens, this.status});
|
||||||
final String label;
|
final String label;
|
||||||
|
|||||||
@@ -145,14 +145,29 @@ String? _firstExisting(List<String> candidates) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build expanded PATH inline — must be self-contained for isolate use.
|
/// Build expanded PATH inline — must be self-contained for isolate use.
|
||||||
String _expandedPath() {
|
String _expandedPath() => expandToolPath(
|
||||||
final base = Platform.environment['PATH'] ?? '';
|
Platform.environment['PATH'] ?? '',
|
||||||
if (!Platform.isMacOS) return base;
|
isMac: Platform.isMacOS,
|
||||||
final home = Platform.environment['HOME'] ?? '';
|
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>[
|
final extras = <String>[
|
||||||
if (home.isNotEmpty) '$home/.local/bin',
|
if (h.isNotEmpty) '$h/.local/bin',
|
||||||
'/opt/homebrew/bin',
|
if (isMac) '/opt/homebrew/bin',
|
||||||
'/opt/homebrew/sbin',
|
if (isMac) '/opt/homebrew/sbin',
|
||||||
'/usr/local/bin',
|
'/usr/local/bin',
|
||||||
];
|
];
|
||||||
final existing = base.split(':').toSet();
|
final existing = base.split(':').toSet();
|
||||||
|
|||||||
@@ -116,7 +116,11 @@ Future<void> main() async {
|
|||||||
McpServer? mcpServer;
|
McpServer? mcpServer;
|
||||||
final ipcLog = Logger();
|
final ipcLog = Logger();
|
||||||
|
|
||||||
Future<void> swapIpcServer(DaemonDispatcher dispatcher, Directory workRoot) async {
|
// IPC-server swaps must run one-at-a-time — see the swapIpcServer wrapper
|
||||||
|
// below doSwapIpcServer for why. (T-352)
|
||||||
|
Future<void> swapChain = Future<void>.value();
|
||||||
|
|
||||||
|
Future<void> doSwapIpcServer(DaemonDispatcher dispatcher, Directory workRoot) async {
|
||||||
if (kIsWeb) return;
|
if (kIsWeb) return;
|
||||||
// Already serving this exact workspace? Reuse the live server.
|
// Already serving this exact workspace? Reuse the live server.
|
||||||
// The startup factory binds the launch CWD, then the project-open
|
// The startup factory binds the launch CWD, then the project-open
|
||||||
@@ -173,6 +177,22 @@ Future<void> main() async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Serialize IPC-server swaps. The boot factory fires a swap to the launch
|
||||||
|
// CWD with unawaited(); the project-open flow then fires another to the
|
||||||
|
// real repo. Unserialized, the two interleave and the late-finishing boot
|
||||||
|
// swap can clobber the repo bind — reconnecting the daemon client to the
|
||||||
|
// launch-CWD (HOME) socket, so pql/git/files run against the wrong
|
||||||
|
// workspace. That surfaced as the ticket/decision sidebars failing on first
|
||||||
|
// load (stale/global pql.db) yet working after a manual refresh. Chaining
|
||||||
|
// every swap makes them apply in call order; the repo swap is issued last
|
||||||
|
// and therefore wins. (T-352)
|
||||||
|
Future<void> swapIpcServer(DaemonDispatcher dispatcher, Directory workRoot) {
|
||||||
|
final next = swapChain.then((_) => doSwapIpcServer(dispatcher, workRoot));
|
||||||
|
// A failed swap must not break the chain for the next one.
|
||||||
|
swapChain = next.catchError((Object _) {});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
DaemonDispatcher buildDispatcher(
|
DaemonDispatcher buildDispatcher(
|
||||||
DaemonBus events,
|
DaemonBus events,
|
||||||
Toolchain tc,
|
Toolchain tc,
|
||||||
|
|||||||
@@ -190,6 +190,9 @@ class IpcServer {
|
|||||||
final trimmed = line.trim();
|
final trimmed = line.trim();
|
||||||
if (trimmed.isEmpty) return;
|
if (trimmed.isEmpty) return;
|
||||||
IpcResponse response;
|
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 {
|
try {
|
||||||
final msg = IpcMessage.decode(trimmed);
|
final msg = IpcMessage.decode(trimmed);
|
||||||
if (msg is! IpcRequest) {
|
if (msg is! IpcRequest) {
|
||||||
@@ -206,6 +209,7 @@ class IpcServer {
|
|||||||
// streaming check sees the unwrapped command (T-129). Plain
|
// streaming check sees the unwrapped command (T-129). Plain
|
||||||
// typed requests skip this path.
|
// typed requests skip this path.
|
||||||
var req = msg;
|
var req = msg;
|
||||||
|
reqCmd = req.cmd;
|
||||||
if (req.cmd == argvSentinelCmd) {
|
if (req.cmd == argvSentinelCmd) {
|
||||||
final result = unwrapArgvRequest(req);
|
final result = unwrapArgvRequest(req);
|
||||||
if (result is ArgvError) {
|
if (result is ArgvError) {
|
||||||
@@ -220,6 +224,7 @@ class IpcServer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
req = (result as ArgvParsed).request;
|
req = (result as ArgvParsed).request;
|
||||||
|
reqCmd = req.cmd;
|
||||||
}
|
}
|
||||||
if (_isTailSubscribe(req)) {
|
if (_isTailSubscribe(req)) {
|
||||||
// Long-lived subscription branch (T-129). Send the streaming
|
// Long-lived subscription branch (T-129). Send the streaming
|
||||||
@@ -240,7 +245,7 @@ class IpcServer {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (e, st) {
|
} 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(
|
response = IpcResponse.err(
|
||||||
id: '',
|
id: '',
|
||||||
error: IpcError(
|
error: IpcError(
|
||||||
|
|||||||
@@ -162,7 +162,13 @@ class PqlClient {
|
|||||||
return const {};
|
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 {
|
Future<Object?> _run(List<String> args) async {
|
||||||
|
for (var attempt = 1; attempt <= _kMaxAttempts; attempt++) {
|
||||||
final ProcessResult r;
|
final ProcessResult r;
|
||||||
try {
|
try {
|
||||||
r = await Process.run(
|
r = await Process.run(
|
||||||
@@ -181,6 +187,15 @@ class PqlClient {
|
|||||||
// pql 1.5+ returns exit 0 with an empty `[]` for zero matches, so any
|
// 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).
|
// non-zero exit is a real error (older pql used exit 2 for empty).
|
||||||
if (r.exitCode != 0) {
|
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(
|
throw PqlException(
|
||||||
'pql ${args.first} failed',
|
'pql ${args.first} failed',
|
||||||
exitCode: r.exitCode,
|
exitCode: r.exitCode,
|
||||||
@@ -191,4 +206,15 @@ class PqlClient {
|
|||||||
if (stdout.isEmpty) return null;
|
if (stdout.isEmpty) return null;
|
||||||
return jsonDecode(stdout);
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_tappable.dart';
|
||||||
import 'package:clide/widgets/src/clide_text.dart';
|
import 'package:clide/widgets/src/clide_text.dart';
|
||||||
import 'package:clide/widgets/src/icons/chevron.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:clide/widgets/src/typography.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
@@ -116,7 +117,9 @@ class _ClideCollapserCardState extends State<ClideCollapserCard> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: ClideText(
|
child: ClideText(
|
||||||
widget.collapsedSummary!,
|
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,
|
fontFamily: clideMonoFamily,
|
||||||
color: tokens.globalTextMuted,
|
color: tokens.globalTextMuted,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
@@ -139,7 +142,7 @@ class _ClideCollapserCardState extends State<ClideCollapserCard> {
|
|||||||
// Status — the icon hard against the right edge.
|
// Status — the icon hard against the right edge.
|
||||||
if (widget.status != null) ...[
|
if (widget.status != null) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
ClideStatusIndicator(status: widget.status!, size: 12),
|
ClideStatusIndicator(status: widget.status!, size: clideIconHero),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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/icons/phosphor.dart';
|
||||||
import 'package:clide/widgets/src/typography.dart';
|
import 'package:clide/widgets/src/typography.dart';
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
@@ -61,6 +62,41 @@ class _ClideLightboxState extends State<ClideLightbox> {
|
|||||||
|
|
||||||
void _reset() => _tc.value = Matrix4.identity();
|
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) {
|
void _onScroll(PointerSignalEvent e) {
|
||||||
if (e is! PointerScrollEvent) return;
|
if (e is! PointerScrollEvent) return;
|
||||||
final current = _tc.value.getMaxScaleOnAxis();
|
final current = _tc.value.getMaxScaleOnAxis();
|
||||||
@@ -101,6 +137,7 @@ class _ClideLightboxState extends State<ClideLightbox> {
|
|||||||
onPointerSignal: _onScroll,
|
onPointerSignal: _onScroll,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onDoubleTap: _reset,
|
onDoubleTap: _reset,
|
||||||
|
onTapUp: _onTapUp,
|
||||||
child: InteractiveViewer(
|
child: InteractiveViewer(
|
||||||
transformationController: _tc,
|
transformationController: _tc,
|
||||||
minScale: widget.minScale,
|
minScale: widget.minScale,
|
||||||
|
|||||||
@@ -19,13 +19,23 @@ typedef ImageTokenBuilder = Widget Function(String path);
|
|||||||
/// it (e.g. the OS URL handler).
|
/// it (e.g. the OS URL handler).
|
||||||
typedef LinkTapCallback = void Function(String url);
|
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
|
/// 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
|
/// 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
|
/// of optional callbacks. All optional; a null hook leaves that affordance
|
||||||
/// inert (the text renders, just not interactive).
|
/// inert (the text renders, just not interactive).
|
||||||
@immutable
|
@immutable
|
||||||
class ClideMarkdownHooks {
|
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).
|
/// Tap a governance/ticket ref (T-281, D-77, …) → open the record (T-279).
|
||||||
final RecordTapCallback? onRecordTap;
|
final RecordTapCallback? onRecordTap;
|
||||||
@@ -36,11 +46,18 @@ class ClideMarkdownHooks {
|
|||||||
/// Open an activated http(s) link (T-253).
|
/// Open an activated http(s) link (T-253).
|
||||||
final LinkTapCallback? onLinkTap;
|
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();
|
static const none = ClideMarkdownHooks();
|
||||||
}
|
}
|
||||||
|
|
||||||
class ClideMarkdown extends StatelessWidget {
|
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 _fontSize = 16;
|
||||||
static const double _lineHeight = clideLineHeight;
|
static const double _lineHeight = clideLineHeight;
|
||||||
@@ -62,10 +79,21 @@ class ClideMarkdown extends StatelessWidget {
|
|||||||
/// a preceding space qualifies, which is how the composer emits them.
|
/// 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);
|
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 String source;
|
||||||
final RecordTapCallback? onRecordTap;
|
final RecordTapCallback? onRecordTap;
|
||||||
final ImageTokenBuilder? onImageToken;
|
final ImageTokenBuilder? onImageToken;
|
||||||
final LinkTapCallback? onLinkTap;
|
final LinkTapCallback? onLinkTap;
|
||||||
|
final FileRefResolver? resolveFileRef;
|
||||||
|
final FileTapCallback? onOpenFile;
|
||||||
|
|
||||||
static String _unescapeHtml(String s) {
|
static String _unescapeHtml(String s) {
|
||||||
return s
|
return s
|
||||||
@@ -82,7 +110,13 @@ class ClideMarkdown extends StatelessWidget {
|
|||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
final doc = md.Document(extensionSet: md.ExtensionSet.gitHubFlavored);
|
final doc = md.Document(extensionSet: md.ExtensionSet.gitHubFlavored);
|
||||||
final nodes = doc.parseLines(source.split('\n'));
|
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);
|
final widgets = _buildNodes(nodes, tokens, hooks);
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@@ -331,8 +365,15 @@ class ClideMarkdown extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
case 'code':
|
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(
|
return TextSpan(
|
||||||
text: _unescapeHtml(el.textContent),
|
text: raw,
|
||||||
style: TextStyle(fontFamily: clideMonoFamily, fontSize: clideFontMono, color: tokens.syntaxString, backgroundColor: tokens.panelBackground),
|
style: TextStyle(fontFamily: clideMonoFamily, fontSize: clideFontMono, color: tokens.syntaxString, backgroundColor: tokens.panelBackground),
|
||||||
);
|
);
|
||||||
case 'a':
|
case 'a':
|
||||||
@@ -346,6 +387,13 @@ class ClideMarkdown extends StatelessWidget {
|
|||||||
if (hooks.onLinkTap != null && href != null && _isHttpUrl(href)) {
|
if (hooks.onLinkTap != null && href != null && _isHttpUrl(href)) {
|
||||||
return _urlLinkSpan(text, href, tokens, hooks.onLinkTap!);
|
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(
|
return TextSpan(
|
||||||
text: text,
|
text: text,
|
||||||
style: TextStyle(color: tokens.globalFocus),
|
style: TextStyle(color: tokens.globalFocus),
|
||||||
@@ -361,40 +409,89 @@ class ClideMarkdown extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Splits plain [text] into spans: pasted-image `@<path>` tokens become inline
|
/// Splits plain [text] into spans: pasted-image `@<path>` tokens become inline
|
||||||
/// image widgets via [onImageToken] (T-236), and bare governance/ticket refs
|
/// image widgets via [onImageToken] (T-236), with the prose between them run
|
||||||
/// (T-281, D-77, Q-5, R-2) become clickable [_recordLinkSpan]s (T-279). With
|
/// through [_linkifyProse] (record + file refs). With no hooks (or no match)
|
||||||
/// neither callback (or no match) the text passes through unchanged.
|
/// the text passes through unchanged.
|
||||||
static List<InlineSpan> _linkifyText(String text, SurfaceTokens tokens, ClideMarkdownHooks hooks) {
|
static List<InlineSpan> _linkifyText(String text, SurfaceTokens tokens, ClideMarkdownHooks hooks) {
|
||||||
if (text.isEmpty) return [TextSpan(text: text)];
|
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) {
|
if (hooks.onImageToken != null) {
|
||||||
final spans = <InlineSpan>[];
|
final spans = <InlineSpan>[];
|
||||||
var last = 0;
|
var last = 0;
|
||||||
for (final m in _imageTokenPattern.allMatches(text)) {
|
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(
|
spans.add(WidgetSpan(
|
||||||
alignment: PlaceholderAlignment.middle,
|
alignment: PlaceholderAlignment.middle,
|
||||||
child: Padding(padding: const EdgeInsets.symmetric(horizontal: 2), child: hooks.onImageToken!(m.group(1)!)),
|
child: Padding(padding: const EdgeInsets.symmetric(horizontal: 2), child: hooks.onImageToken!(m.group(1)!)),
|
||||||
));
|
));
|
||||||
last = m.end;
|
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 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) {
|
/// Linkifies running prose: governance/ticket refs (T-279) and workspace file
|
||||||
if (onRecordTap == null || text.isEmpty) return [TextSpan(text: text)];
|
/// 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>[];
|
final spans = <InlineSpan>[];
|
||||||
var last = 0;
|
var last = 0;
|
||||||
for (final m in _bareRecordPattern.allMatches(text)) {
|
for (final h in hits) {
|
||||||
if (m.start > last) spans.add(TextSpan(text: text.substring(last, m.start)));
|
if (h.start < last) continue; // overlapped by an earlier match
|
||||||
spans.add(_recordLinkSpan(m[0]!, tokens, onRecordTap));
|
if (h.start > last) spans.add(TextSpan(text: text.substring(last, h.start)));
|
||||||
last = m.end;
|
spans.add(h.span);
|
||||||
|
last = h.end;
|
||||||
}
|
}
|
||||||
if (last < text.length) spans.add(TextSpan(text: text.substring(last)));
|
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
|
/// 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;
|
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,
|
/// A clickable http(s) link span (T-253): the link [text] in the focus accent,
|
||||||
/// underlined on hover, opening [href] via [onLinkTap]. Keyboard-activatable
|
/// underlined on hover, opening [href] via [onLinkTap]. Keyboard-activatable
|
||||||
/// (ClideTappable) and tooltipped with the destination.
|
/// (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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,19 +17,40 @@ import 'package:flutter/widgets.dart';
|
|||||||
|
|
||||||
enum ClideRunStatus { running, success, error }
|
enum ClideRunStatus { running, success, error }
|
||||||
|
|
||||||
class ClideStatusIndicator extends StatelessWidget {
|
class ClideStatusIndicator extends StatefulWidget {
|
||||||
const ClideStatusIndicator({super.key, required this.status, this.size = 14});
|
const ClideStatusIndicator({super.key, required this.status, this.size = 14});
|
||||||
|
|
||||||
final ClideRunStatus status;
|
final ClideRunStatus status;
|
||||||
final double size;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
final (Widget glyph, String label) = switch (status) {
|
final key = ValueKey('${widget.status.name}-$_seq');
|
||||||
ClideRunStatus.running => (ClideSpinner(size: size, color: tokens.globalTextMuted, key: const ValueKey('running')), 'running'),
|
final (Widget glyph, String label) = switch (widget.status) {
|
||||||
ClideRunStatus.success => (ClideIcon(const CheckIcon(), size: size, color: tokens.statusSuccess, key: const ValueKey('success')), 'succeeded'),
|
ClideRunStatus.running => (ClideSpinner(size: widget.size, color: tokens.globalTextMuted, key: key), 'running'),
|
||||||
ClideRunStatus.error => (ClideIcon(const CloseIcon(), size: size, color: tokens.statusError, key: const ValueKey('error')), 'failed'),
|
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(
|
return Semantics(
|
||||||
label: label,
|
label: label,
|
||||||
|
|||||||
@@ -85,5 +85,10 @@ const double clideIconHitTarget = 16;
|
|||||||
/// Emphatic icon (standalone affordances, primary action glyphs).
|
/// Emphatic icon (standalone affordances, primary action glyphs).
|
||||||
const double clideIconEmphatic = 18;
|
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).
|
/// Standard control height (tab, button, list row).
|
||||||
const double clideControlHeight = 28;
|
const double clideControlHeight = 28;
|
||||||
|
|||||||
@@ -13,15 +13,24 @@ apply_standard_settings(${BINARY_NAME})
|
|||||||
|
|
||||||
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
|
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)
|
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 flutter)
|
||||||
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
|
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
|
||||||
if(WAYLAND_CLIENT_FOUND)
|
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::WAYLAND_CLIENT)
|
||||||
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::WAYLAND_CLIENT)
|
target_compile_definitions(${BINARY_NAME} PRIVATE HAS_WAYLAND_CLIENT=1)
|
||||||
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}")
|
||||||
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}/runner")
|
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}/runner")
|
||||||
|
|||||||
@@ -113,7 +113,13 @@ static void clide_app_activate(GApplication* application) {
|
|||||||
// D-057: frameless custom chrome.
|
// D-057: frameless custom chrome.
|
||||||
gtk_window_set_decorated(window, FALSE);
|
gtk_window_set_decorated(window, FALSE);
|
||||||
gtk_window_set_title(window, "clide");
|
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, "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);
|
gtk_window_set_default_size(window, 1280, 720);
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ description: >-
|
|||||||
subsystem handlers (pane, files, editor, git, pql), and the
|
subsystem handlers (pane, files, editor, git, pql), and the
|
||||||
extension framework.
|
extension framework.
|
||||||
publish_to: none
|
publish_to: none
|
||||||
version: 2.2.0
|
version: 2.3.2
|
||||||
repository: https://github.com/postmeridiem/clide
|
repository: https://github.com/postmeridiem/clide
|
||||||
# Short user-facing tagline (the welcome subtitle, web meta
|
# Short user-facing tagline (the welcome subtitle, web meta
|
||||||
# description, etc.). Baked into lib/src/build_info.g.dart by
|
# 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());
|
tearDown(() => f.dispose());
|
||||||
|
|
||||||
Future<ConversationController> pumpWith(WidgetTester tester, List<ConversationItem> items,
|
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.physicalSize = const Size(900, 700);
|
||||||
tester.view.devicePixelRatio = 1.0;
|
tester.view.devicePixelRatio = 1.0;
|
||||||
addTearDown(() {
|
addTearDown(() {
|
||||||
@@ -166,7 +169,12 @@ void main() {
|
|||||||
Builder(
|
Builder(
|
||||||
builder: (ctx) => MediaQuery(
|
builder: (ctx) => MediaQuery(
|
||||||
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
|
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);
|
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 {
|
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.
|
// No Agent tool-use to attach to → stays standalone, but relabelled.
|
||||||
await pumpWith(tester, [
|
await pumpWith(tester, [
|
||||||
@@ -679,6 +705,33 @@ void main() {
|
|||||||
expect(find.text('Bash · error'), findsOneWidget);
|
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 {
|
testWidgets('result without a paired tool_use uses plain "result" label (T-168)', (tester) async {
|
||||||
// Orphan result (no matching tool_use in the controller).
|
// Orphan result (no matching tool_use in the controller).
|
||||||
await pumpWith(tester, [
|
await pumpWith(tester, [
|
||||||
|
|||||||
@@ -230,6 +230,35 @@ void main() {
|
|||||||
expect((decision as DenyTool).message, 'write it under docs/ instead');
|
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 {
|
testWidgets('permission: a typed note rides Allow as a follow-up note', (tester) async {
|
||||||
ToolDecision? decision;
|
ToolDecision? decision;
|
||||||
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, d) => decision = d)));
|
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, d) => decision = d)));
|
||||||
@@ -520,6 +549,22 @@ void main() {
|
|||||||
expect(d, isA<DenyTool>());
|
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 {
|
testWidgets('a number key selects a question option', (tester) async {
|
||||||
ToolDecision? d;
|
ToolDecision? d;
|
||||||
await pumpCard(tester, questionPrompt(), (x) => d = x);
|
await pumpCard(tester, questionPrompt(), (x) => d = x);
|
||||||
|
|||||||
@@ -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[0].parentUuid, 'msg-A');
|
||||||
expect(items[1].parentUuid, isNull); // '' → null
|
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', () {
|
group('DecisionsView — concurrent refresh guard', () {
|
||||||
testWidgets('second refresh while one is running sets _pendingRefresh', (tester) async {
|
testWidgets('second refresh while one is running sets _pendingRefresh', (tester) async {
|
||||||
final Completer<IpcResponse> firstListCompleter = Completer();
|
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);
|
await pumpAsync(tester);
|
||||||
expect(calls, greaterThan(before));
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,4 +47,34 @@ void main() {
|
|||||||
expect(v.allOk, isFalse);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,32 @@ void main() {
|
|||||||
expect(find.byType(InteractiveViewer), findsOneWidget);
|
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 {
|
testWidgets('scroll wheel zooms in and out', (tester) async {
|
||||||
await tester.pumpWidget(harness(f, box(() {})));
|
await tester.pumpWidget(harness(f, box(() {})));
|
||||||
await tester.pumpAndSettle();
|
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();
|
await tester.pumpAndSettle();
|
||||||
expect(iconWith(CloseIcon), findsOneWidget);
|
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());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||