From 548e3e17e8e27a9e4703d88ccb7558f9fda383b5 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 9 Jun 2026 18:54:46 +0200 Subject: [PATCH] pin collapse toggles to the status-bar screen edges + flip chevron live (T-294) Two fixes to the just-shipped collapse toggles: - They lived inside the centre StatusbarHost, so they shifted when a pane collapsed and the bar resized. Move them to the OUTER status-bar row ends (screen edges) so they stay put. StatusbarHost reverts to its prior layout. - The toggle was a const widget that read isCollapsed in its own build; Flutter skips identical const children on rebuild, so the chevron never flipped. Pass collapsed/visible in from the parent's arrangement listener so it rebuilds and the chevron tracks the action direction. A 'flips live' test now guards it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 1 + .pql/changelog/tickets/2026-06.sql | 14 ++++ lib/app.dart | 37 +++++---- test/app_collapse_toggle_test.dart | 99 ++++++++++++----------- test/app_statusbar_test.dart | 12 ++- 5 files changed, 95 insertions(+), 68 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index cb074c47..1eab3e0d 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -2013,3 +2013,4 @@ So the collapsed card''s indicator animates spinner<->check as the run grows, ra DONE 2026-06-09 (commit). coalesceEditRuns folds consecutive same-file edits into one ''# edits'' ClideHolderCard; aggregate live status via new ClideStatusIndicator (running/success/error) + ClideSpinner (logo-mark, 3D Y rotation, reduced-motion aware), shared with the activity card. Per user: spinner is a self-contained component (not built on ConversationCard''s mark) with an AnimatedSwitcher seam for a richer spinner→check transition later. Tests: activity_cluster_test (coalesce), conversation_view_test (edits card), clide_status_indicator_test.', NULL, '2026-06-09 16:34:46', '2026-06-09 16:34:46', '2026-06-09 16:34:46', NULL, '899916f67fe3c12a2ac4d231630bd34d', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-296', 'status', 'ready', 'done', NULL, '2026-06-09 16:34:46', '2026-06-09 16:34:46', '2026-06-09 16:34:46', NULL, '9c76b18710ed6e17c878dea8f92ad881', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-132', 'status', 'backlog', 'ready', NULL, '2026-06-09 16:54:28', '2026-06-09 16:54:28', '2026-06-09 16:54:28', NULL, '992ed635baad8cd22fbc9a7d1fee52be', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 95ae141e..81930ff4 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -3400,3 +3400,17 @@ Notes: - Both should be low-noise, runnable locally and in CI (candidate home: ci/security.sh + a Makefile target, surfaced via push-check). - Scope is automation only — the manual review discipline already exists; this makes it enforced rather than convention. - Source report (gemini-report.md) is being removed from the repo once this ticket captures its only actionable content.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-09 16:42:47', '2026-06-09 16:42:47', NULL, '3a10dc76cf86083d128080c29a41ac18', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-132', 'epic', NULL, 'Native Claude integration: transcript-rendered conversation + tmux agent-team panels', 'Replace the terminal-based Claude pane with native rendering driven by Claude Code''s transcript JSONL (with cross-widget text selection), and surface experimental tmux agent teams (lead + teammates) as tiled native panels plus a team-meta sidebar. The terminal is retained only as a general IDE tool. + +Why: +1. No cross-widget text selection/copy in clide''s native widgets today (regression vs a terminal). +2. PTY/TUI rendering is the fragile, OS-variant part of the Claude pane. +3. Claude Code''s experimental tmux team mode (lead + N teammates, each a tmux pane) is the flagship capability to surface as real GUI panels, not scraped TUI. + +Direction: run Claude headless in tmux; render its conversation natively from the transcript JSONL; lead panel (left, with composer) + teammate tiles (right) in a responsive auto-wrap grid (1->2->3 cols); team-meta sidebar with roster + token budget. + +Principle: Claude-centric first, CLI-first (D-6) a strong second. Accept isolated, version-pinned coupling to Claude Code internal contracts where it serves the Claude integration; preserve CLI/event surfaces where sensible. + +Acceptance: all child tickets done; D-record landed; Claude pane renders natively with working select+copy and no terminal; a tmux team surfaces as lead + auto-wrapping teammate tiles + meta sidebar; all fragile CC-internals parsing isolated and version-pinned. + +Source: plan iridescent-tinkering-umbrella (2026-05-22), grounded in an-idea.md + read-only validation of the transcript schema this session.', 'ready', 'high', NULL, NULL, NULL, '2026-05-22 15:45:15', '2026-06-09 16:54:28', NULL, 'e921f72c985f66e7d12b0a29b51bea7f', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/lib/app.dart b/lib/app.dart index 2c9346f4..750186ad 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -292,6 +292,9 @@ class RootLayout extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // Collapse toggles are pinned to the screen edges (outermost + // children) so they never shift when a pane collapses (T-294). + StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: sidebarCollapsed, visible: sidebarVisible), if (sidebarVisible && !sidebarCollapsed) SizedBox(width: sidebarSize, child: _BottomRail(slot: Slots.sidebar)) else if (sidebarVisible && sidebarCollapsed) @@ -301,6 +304,7 @@ class RootLayout extends StatelessWidget { SizedBox(width: contextSize, child: _BottomRail(slot: Slots.contextPanel)) else if (contextVisible && contextCollapsed) const SizedBox(width: ClideSpine.width), + StatusbarCollapseToggle(slot: Slots.contextPanel, collapsed: contextCollapsed, visible: contextVisible), ], ), ), @@ -1097,10 +1101,19 @@ class _BottomRail extends StatelessWidget { /// caret-line chevron per `arrangement.isCollapsed` (outward = expand, inward = /// collapse). The collapse behaviour itself lives in the commands (D-51/D-54); /// this is the mouse affordance for the keyboard/CLI-addressable action (D-6). -class _CollapseToggle extends StatelessWidget { - const _CollapseToggle({required this.slot}); +/// A fixed collapse/expand toggle pinned to a screen edge of the status bar +/// (T-294). Lives at the outer ends of the bar — NOT inside the centre +/// [StatusbarHost] — so it never shifts when a pane collapses and the centre +/// bar resizes. [collapsed]/[visible] are passed in (not read from the +/// arrangement here) so the widget varies with state and rebuilds when its +/// parent's `ListenableBuilder` fires — a const widget reading the arrangement +/// itself is skipped as identical on rebuild, freezing the chevron. +class StatusbarCollapseToggle extends StatelessWidget { + const StatusbarCollapseToggle({super.key, required this.slot, required this.collapsed, required this.visible}); final SlotId slot; + final bool collapsed; + final bool visible; bool get _isSidebar => slot == Slots.sidebar; @@ -1108,10 +1121,9 @@ class _CollapseToggle extends StatelessWidget { Widget build(BuildContext context) { final kernel = ClideKernel.of(context); final tokens = ClideTheme.of(context).surface; - if (!kernel.arrangement.isVisible(slot)) return const SizedBox(width: 24); - final collapsed = kernel.arrangement.isCollapsed(slot); - // Mirror + flip: the chevron points OUT (toward the edge the pane lives on) - // to expand a collapsed pane, and IN to collapse an open one. + if (!visible) return const SizedBox(width: 24); + // The chevron points the DIRECTION OF THE ACTION: collapsing tucks the pane + // toward its own edge, expanding brings it back toward the centre. final icon = _isSidebar ? (collapsed ? PhosphorIcons.caretLineRight : PhosphorIcons.caretLineLeft) : (collapsed ? PhosphorIcons.caretLineLeft : PhosphorIcons.caretLineRight); @@ -1139,9 +1151,7 @@ class StatusbarHost extends StatelessWidget { final kernel = ClideKernel.of(context); final tokens = ClideTheme.of(context).surface; return ListenableBuilder( - // Also listen to the arrangement so the toggle chevrons flip with the - // collapsed state (T-294). - listenable: Listenable.merge([kernel.panels, kernel.arrangement]), + listenable: kernel.panels, builder: (ctx, _) { final items = kernel.panels.contributionsFor(Slots.statusbar).whereType().toList(); final left = items.where((i) => i.priority < 100).toList(); @@ -1152,15 +1162,13 @@ class StatusbarHost extends StatelessWidget { // trails it at intrinsic width — so it hugs the workspace block's // right edge by construction, no Spacer to fight a flex item (T-239). // Left items with flex > 0 wrap in Flexible(loose) so they yield width - // when tight (T-160). The collapse toggles bookend both ends at a fixed - // ~24px each; the status items sit between them (T-294). + // when tight (T-160). return Container( color: tokens.chromeBackground, - padding: const EdgeInsets.symmetric(horizontal: 4), + padding: const EdgeInsets.symmetric(horizontal: 8), child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - const _CollapseToggle(slot: Slots.sidebar), Expanded( child: Row( crossAxisAlignment: CrossAxisAlignment.center, @@ -1171,7 +1179,6 @@ class StatusbarHost extends StatelessWidget { ), ), for (final item in right) item.build(ctx), - const _CollapseToggle(slot: Slots.contextPanel), ], ), ); diff --git a/test/app_collapse_toggle_test.dart b/test/app_collapse_toggle_test.dart index f14d004d..37bf0770 100644 --- a/test/app_collapse_toggle_test.dart +++ b/test/app_collapse_toggle_test.dart @@ -1,6 +1,6 @@ -/// Status-bar collapse toggles (T-294): a fixed cell bookends each end of the -/// status bar, flips a caret-line chevron per arrangement.isCollapsed, and fires -/// the existing sidebar.collapse / context.collapse commands. +/// Status-bar collapse toggle (T-294): a fixed cell pinned to a screen edge of +/// the bar, flipping a caret-line chevron to reflect the direction of the +/// action and firing the existing collapse commands. library; import 'package:clide/app.dart'; @@ -15,18 +15,15 @@ import 'helpers/kernel_fixture.dart'; Finder _icon(PhosphorIconPainter p) => find.byWidgetPredicate((w) => w is ClideIcon && w.painter == p); -Widget _bar(KernelFixture f) => Directionality( +Widget _host(KernelFixture f, Widget child) => Directionality( textDirection: TextDirection.ltr, child: ClideKernel( services: f.services, child: ClideTheme( controller: f.services.theme, - child: const MediaQuery( - data: MediaQueryData(size: Size(800, 200)), - child: Align( - alignment: Alignment.topLeft, - child: SizedBox(width: 800, height: 26, child: StatusbarHost()), - ), + child: MediaQuery( + data: const MediaQueryData(size: Size(800, 200)), + child: Align(alignment: Alignment.topLeft, child: SizedBox(height: 26, child: child)), ), ), ), @@ -34,58 +31,68 @@ Widget _bar(KernelFixture f) => Directionality( void main() { late KernelFixture f; - - setUp(() async { - f = await KernelFixture.create(); - f.services.arrangement.applyPreset(const LayoutPresetContribution( - id: 'test', - displayName: 'test', - slots: [ - LayoutSlot(slot: Slots.sidebar, position: SlotPosition.left, visible: true), - LayoutSlot(slot: Slots.contextPanel, position: SlotPosition.right, visible: true), - ], - )); - f.services.extensions.register(DefaultLayoutExtension()); - await f.services.extensions.activate('builtin.default-layout'); - }); + setUp(() async => f = await KernelFixture.create()); tearDown(() => f.dispose()); - testWidgets('both toggles show, chevrons point inward when panes are open', (tester) async { - await tester.pumpWidget(_bar(f)); + testWidgets('chevron points the direction of the action (collapse vs expand)', (tester) async { + // Sidebar open → action is collapse (leftward) → caret-line-left. + await tester.pumpWidget(_host(f, const StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: false, visible: true))); await tester.pump(); - // Sidebar (left end) collapses leftward → caret-line-left; context (right - // end) collapses rightward → caret-line-right. expect(_icon(PhosphorIcons.caretLineLeft), findsOneWidget); + + // Sidebar collapsed → action is expand (rightward) → caret-line-right. + await tester.pumpWidget(_host(f, const StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: true, visible: true))); + await tester.pump(); + expect(_icon(PhosphorIcons.caretLineRight), findsOneWidget); + + // The context toggle mirrors it: open → collapse rightward, collapsed → expand leftward. + await tester.pumpWidget(_host(f, const StatusbarCollapseToggle(slot: Slots.contextPanel, collapsed: false, visible: true))); + await tester.pump(); expect(_icon(PhosphorIcons.caretLineRight), findsOneWidget); }); - testWidgets('a collapsed pane flips its chevron outward (expand affordance)', (tester) async { - f.services.arrangement.setCollapsed(Slots.sidebar, true); - await tester.pumpWidget(_bar(f)); + testWidgets('the chevron flips live when the collapsed state changes', (tester) async { + var collapsed = false; + late StateSetter setOuter; + await tester.pumpWidget(_host( + f, + StatefulBuilder(builder: (ctx, setState) { + setOuter = setState; + return StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: collapsed, visible: true); + }), + )); await tester.pump(); - // Sidebar now collapsed → its chevron points right (expand); context still - // open → right. So two right-pointing, none left. - expect(_icon(PhosphorIcons.caretLineRight), findsNWidgets(2)); + expect(_icon(PhosphorIcons.caretLineLeft), findsOneWidget); + + setOuter(() => collapsed = true); + await tester.pump(); + expect(_icon(PhosphorIcons.caretLineRight), findsOneWidget, reason: 'rebuilds and flips on state change'); expect(_icon(PhosphorIcons.caretLineLeft), findsNothing); }); - testWidgets('tapping the sidebar toggle fires sidebar.collapse', (tester) async { - await tester.pumpWidget(_bar(f)); + testWidgets('a hidden pane reserves the cell but shows no chevron', (tester) async { + await tester.pumpWidget(_host(f, const StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: false, visible: false))); + await tester.pump(); + expect(_icon(PhosphorIcons.caretLineLeft), findsNothing); + expect(_icon(PhosphorIcons.caretLineRight), findsNothing); + }); + + testWidgets('tapping fires the matching collapse command', (tester) async { + f.services.arrangement.applyPreset(const LayoutPresetContribution( + id: 'test', + displayName: 'test', + slots: [LayoutSlot(slot: Slots.sidebar, position: SlotPosition.left, visible: true)], + )); + f.services.extensions.register(DefaultLayoutExtension()); + await f.services.extensions.activate('builtin.default-layout'); + + await tester.pumpWidget(_host(f, const StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: false, visible: true))); await tester.pump(); expect(f.services.arrangement.isCollapsed(Slots.sidebar), isFalse); - await tester.tap(_icon(PhosphorIcons.caretLineLeft)); // the open-sidebar toggle + await tester.tap(_icon(PhosphorIcons.caretLineLeft)); await tester.pump(); expect(f.services.arrangement.isCollapsed(Slots.sidebar), isTrue); }); - - testWidgets('a hidden pane reserves the cell but shows no toggle', (tester) async { - f.services.arrangement.setVisible(Slots.contextPanel, false); - await tester.pumpWidget(_bar(f)); - await tester.pump(); - // Sidebar toggle present (open → left); context hidden → no right chevron. - expect(_icon(PhosphorIcons.caretLineLeft), findsOneWidget); - expect(_icon(PhosphorIcons.caretLineRight), findsNothing); - }); } diff --git a/test/app_statusbar_test.dart b/test/app_statusbar_test.dart index dcdbfad3..519bb76a 100644 --- a/test/app_statusbar_test.dart +++ b/test/app_statusbar_test.dart @@ -69,11 +69,10 @@ void main() { for (final width in [600.0, 3440.0]) { await pumpAt(tester, width); expect(tester.takeException(), isNull, reason: 'width=$width'); - // StatusbarHost pads 4px each side and reserves a fixed 24px collapse- - // toggle cell at each end (T-294), so status items sit 28px inside both - // edges: right item's right edge ≈ width - 28, left item's left ≈ 28. - expect(tester.getTopRight(find.text('RIGHT')).dx, closeTo(width - 28, 1.0), reason: 'right group not at edge at width=$width'); - expect(tester.getTopLeft(find.text('LEFT')).dx, closeTo(28, 1.0), reason: 'left not at start at width=$width'); + // StatusbarHost pads 8px each side → right item's right edge ≈ width - 8. + // (Collapse toggles live at the bar's screen edges, outside this host.) + expect(tester.getTopRight(find.text('RIGHT')).dx, closeTo(width - 8, 1.0), reason: 'right group not at edge at width=$width'); + expect(tester.getTopLeft(find.text('LEFT')).dx, closeTo(8, 1.0), reason: 'left not at start at width=$width'); } }); @@ -85,7 +84,6 @@ void main() { )); await pumpAt(tester, 3440.0); expect(tester.takeException(), isNull); - // 4px pad + 24px reserved toggle cell at the right end (T-294). - expect(tester.getTopRight(find.text('R2')).dx, closeTo(3440 - 28, 1.0)); + expect(tester.getTopRight(find.text('R2')).dx, closeTo(3440 - 8, 1.0)); }); }