diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index a5ae304b..f686b9bb 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2275,3 +2275,6 @@ Fix direction: give the in-pane context slot a flexible/bounded width in the sta --- Refinement (2026-05-30, /whats-next): pulled into the T-167/T-168 batch and sequenced FIRST. T-168 adds live cost / permission-mode / context / rate-limit to this same in-pane status slot; the Flexible/marquee fix here must land before T-168 lengthens the line. After this fix, T-168 can safely add fields to the slot.', NULL, '2026-05-30 10:44:10', '2026-05-30 10:44:10', '2026-05-30 10:44:10', NULL, '4ad98d4a7db42e295fba6dff0988ad3b', 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-167', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 10:48:32', '2026-05-30 10:48:32', '2026-05-30 10:48:32', NULL, '229c9cb92eb8554dbba51ca65e1eeba9', 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-168', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 10:48:32', '2026-05-30 10:48:32', '2026-05-30 10:48:32', NULL, '4469254d98eba45964ca51d5855e5014', 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-160', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 10:48:32', '2026-05-30 10:48:32', '2026-05-30 10:48:32', NULL, 'e410b6f78d0e4d96bc37f3a351d72767', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 6855756a..d18933c6 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2789,3 +2789,30 @@ Fix direction: give the in-pane context slot a flexible/bounded width in the sta --- Refinement (2026-05-30, /whats-next): pulled into the T-167/T-168 batch and sequenced FIRST. T-168 adds live cost / permission-mode / context / rate-limit to this same in-pane status slot; the Flexible/marquee fix here must land before T-168 lengthens the line. After this fix, T-168 can safely add fields to the slot.', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-24 09:13:42', '2026-05-30 10:44:10', NULL, '5ed66d193e73c29141a8525a44a242f7', 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-168', 'task', 'T-164', 'P1: native rendering leveled up — typed event cards + streaming + live status', 'Exploit the structured stream the transcript-tail never cleanly gave. Per-tool cards from tool_use input (Edit/Write → diff, Bash → command+output, Read/Grep → file/query); pair each tool_result to its tool_use (with is_error) so diffs/failures render in place; stream assistant text + thinking blocks token-by-token via --include-partial-messages; surface live model / permission-mode / context-tokens / cost and rate_limit_event straight off the init + result events (retires the ClaudeConfig probe + status-bar guessing for the active pane); render structured errors as events. Acceptance: a session with edits/bash/reads shows the right card per tool with paired results; replies stream; the status line reflects live model/mode/context/cost from events; widget + fixture tests. Blocked by T-165. + +--- +Refinement (2026-05-30, /whats-next) — five seam decisions resolved: + +1. tool_use/result pairing: add a Map toolUseById index to ConversationController. The result card looks up its tool_use and renders the Edit/Write diff (reuse prompt_card''s _editBody style) or the is_error failure IN PLACE in the result card. Files: conversation_controller.dart (index), conversation_view.dart (_toolUse / _toolResult bodies — they currently render indented JSON only and can''t see each other). + +2. Per-tool card bodies: Edit/Write → diff; Bash → command+output; Read/Grep → file/query. Selected by tool name off the paired tool_use input. + +3. Partial-message streaming: pass --include-partial-messages in ClaudeStreamJsonProcess.start(); accumulate partials keyed by message.id in StreamJsonSession._onLine BEFORE emitting to the controller, so one item updates in place (no duplicate AssistantTextMessage items). Thinking blocks stream the same way. Do NOT add a new streaming item type in the controller. + +4. Live cost/context (was an open gap, defaulted): extend SessionStatus (transcript_reader.dart) with cost + contextWindow fields; extract them from result events in stream_json_session.dart::_statusFromEvent (currently only handles system/init) — total_cost_usd + modelUsage..contextWindow per the spike doc docs/spikes/cc-stream-json-control-protocol-2.1.150.md. + +5. rate_limit_event + the focus status line: surface live model/mode/context/cost/rate-limit in the in-pane status slot. This DEPENDS ON T-160 (status-bar overflow / marquee) which is pulled into this batch and sequenced FIRST — do not lengthen the status line until T-160''s Flexible/marquee fix lands. ClaudeConfig.ensureProbe() is retired only for the ACTIVE-pane status; the probe survives for slash-command typeahead. + +Files an implementer touches: conversation_view.dart, conversation_controller.dart, transcript_reader.dart, stream_json_session.dart, claude_pane.dart, plus test/builtin/claude/*. Commit the in-flight prompt_card.dart change first (disjoint surface — permission-card Write-description dedup). + +Sequencing within the batch: T-160 → T-168.', 'in_progress', 'medium', NULL, NULL, 'D-77', '2026-05-24 16:26:39', '2026-05-30 10:48:32', NULL, '24a7adcd7c1e8cb3f9c4e042c43aa9b2', 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-167', 'task', 'T-164', 'P1: session persistence + /clear + /resume on stream-json (--resume)', 'Move session lifecycle onto the stream-json model. New session: --session-id ; resume an existing one: --resume (T-161 confirmed --session-id refuses an existing id). Re-implement the clide-owned commands on this model: /clear = new fresh session, /resume = picker then resume the picked id. Retire the tmux session lifecycle for Claude (kill/hasSession/reap, new-session -A) and the --session-id-in-use handling once the transport no longer uses tmux. Acceptance: restart resumes the primary''s session; /clear starts empty; /resume switches to a picked session; all without tmux; tests for the new/resume argv selection and the command flows. Supersedes the mechanics of T-156/T-161. Blocked by T-165. Amends D-41 (tmux persistence → --resume).', 'in_progress', 'high', NULL, NULL, 'D-77', '2026-05-24 16:26:30', '2026-05-30 10:48:32', NULL, '4c9095cfb7c8caa57c4b720582a73ef7', 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-160', 'bug', 'T-132', 'Status bar overflows instead of marquee-scrolling the in-pane slot', 'Observed via make run (2026-05-24): the bottom status bar shows a RenderFlex ''overflowed by 77 PIXELS'' stripe after the in-pane context line (opus 4.7 · default · 21k ctx · 10 skills); the ClideMarquee never engages. + +Cause: StatusbarHost (lib/app.dart ~1143) lays items in a Row [left items..., Spacer(), right items...], each at INTRINSIC width with no Flexible. PaneContextStatusItem wraps its text in ConstrainedBox(maxWidth:360) + ClideMarquee, but because the text (~280px) is under 360 the slot takes content width, so the marquee''s viewport == its content and it never scrolls. T-154 appended ''· N skills'', lengthening the line enough that git-branch + context slot + right items exceed the bar width -> the Row overflows (~77px) instead of the slot shrinking + marquee scrolling. + +Fix direction: give the in-pane context slot a flexible/bounded width in the status bar (e.g. wrap it in Flexible(fit: loose), or have the bar allot it a shrinkable share) so it yields width when the bar is tight and ClideMarquee scrolls within the allotted width. Verify the interaction with the Spacer and the right-aligned items (priority>=100), and reconsider the fixed maxWidth:360. Acceptance: at narrow widths the in-pane slot marquee-scrolls and the status bar never shows an overflow stripe; widget test at a constrained width. Introduced by T-150 (marquee) + T-154 (longer line). + +--- +Refinement (2026-05-30, /whats-next): pulled into the T-167/T-168 batch and sequenced FIRST. T-168 adds live cost / permission-mode / context / rate-limit to this same in-pane status slot; the Flexible/marquee fix here must land before T-168 lengthens the line. After this fix, T-168 can safely add fields to the slot.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-24 09:13:42', '2026-05-30 10:48:32', NULL, 'be801b09fd2aeb3a4bb71b782499f9a7', 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/CHANGELOG.md b/CHANGELOG.md index a711affb..decdabdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Fixed +- Status bar no longer overflows when the focused-pane context line is long + — the in-pane slot now takes a flexible share of the bar and marquee-scrolls + within it instead of pushing the row past its width. - Write/Edit permission cards no longer print the file path twice — the description line is suppressed when it just repeats `file_path`. - Resumed Claude session no longer starts with an empty pane — `claude diff --git a/lib/app.dart b/lib/app.dart index da5556a9..5a556dc7 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1140,10 +1140,16 @@ class StatusbarHost extends StatelessWidget { color: tokens.chromeBackground, padding: const EdgeInsets.symmetric(horizontal: 8), alignment: Alignment.center, + // Left items with flex > 0 are wrapped in Flexible(loose) so they + // yield width when the bar is tight; their contained ClideMarquee + // then scrolls within the allotted bounds (T-160). Items with + // flex == 0 (default) stay at intrinsic width. Right-side items + // (priority >= 100) are always intrinsic-width. child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - for (final item in left) item.build(ctx), + for (final item in left) + if (item.flex > 0) Flexible(flex: item.flex, fit: FlexFit.loose, child: item.build(ctx)) else item.build(ctx), const Spacer(), for (final item in right) item.build(ctx), ], diff --git a/lib/builtin/claude/src/pane_context_status.dart b/lib/builtin/claude/src/pane_context_status.dart index eb2d678d..ef6ea323 100644 --- a/lib/builtin/claude/src/pane_context_status.dart +++ b/lib/builtin/claude/src/pane_context_status.dart @@ -11,9 +11,6 @@ import 'package:clide/kernel/kernel.dart'; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/widgets.dart'; -/// Max width the slot occupies in the status bar before marquee kicks in. -const double _slotMaxWidth = 360; - /// Fixed slot height — panes can render anything, but not blow up the bar. const double _slotHeight = 16; @@ -28,14 +25,15 @@ class PaneContextStatusItem extends StatelessWidget { builder: (ctx, _) { final widget = focus.activeStatusWidget; if (widget == null) return const SizedBox.shrink(); + // The parent StatusbarHost wraps this item in Flexible(loose) so the + // Row hands us a bounded maxWidth (T-160). ClideMarquee receives that + // constraint via LayoutBuilder and scrolls when content exceeds it — + // no ConstrainedBox(maxWidth) cap needed here. return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: SizedBox( height: _slotHeight, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: _slotMaxWidth), - child: ClideMarquee(child: widget), - ), + child: ClideMarquee(child: widget), ), ); }, diff --git a/lib/extension/src/contribution.dart b/lib/extension/src/contribution.dart index 09aa76ba..af5d8305 100644 --- a/lib/extension/src/contribution.dart +++ b/lib/extension/src/contribution.dart @@ -61,6 +61,7 @@ class StatusItemContribution extends ContributionPoint { required this.build, this.priority = 0, this.listenable, + this.flex = 0, }); @override @@ -68,6 +69,13 @@ class StatusItemContribution extends ContributionPoint { final WidgetBuilder build; final int priority; final Listenable? listenable; + + /// When > 0, the status bar wraps this item in + /// `Flexible(flex: flex, fit: FlexFit.loose)` so it yields width when the + /// bar is tight and any contained [ClideMarquee] can scroll (T-160). + /// Defaults to 0 (intrinsic/non-flex). Only meaningful for left-side items + /// (priority < 100); right-side items are always intrinsic-width. + final int flex; } /// A button in the main toolbar. diff --git a/test/builtin/claude/pane_context_status_test.dart b/test/builtin/claude/pane_context_status_test.dart index a0a1a21e..9f7062a9 100644 --- a/test/builtin/claude/pane_context_status_test.dart +++ b/test/builtin/claude/pane_context_status_test.dart @@ -1,16 +1,54 @@ -/// Tests for the focus-driven status-bar slot item (T-150): it renders -/// the focused pane's status widget and clears when focus moves to a -/// pane with no status. +/// Tests for the focus-driven status-bar slot item (T-150 / T-160): it +/// renders the focused pane's status widget and clears when focus moves +/// to a pane with no status; at a constrained width the slot never +/// overflows and ClideMarquee gets a bounded viewport. library; import 'package:clide/builtin/claude/src/pane_context_status.dart'; -import 'package:clide/kernel/src/panels/slot_id.dart'; +import 'package:clide/kernel/kernel.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'; +/// Wraps [child] in a Row with Flexible(loose), mirroring the layout that +/// StatusbarHost applies to left-side flex items (T-160), inside a +/// [SizedBox] of the given [width]. +/// +/// Deliberately does NOT use the shared [harness] here: that one mounts an +/// `Overlay(canSizeOverlay: true)` over a zero-size MediaQuery, which runs an +/// intrinsic-sizing pass and hands children unbounded width — so a `Flexible` +/// never bounds the marquee and this test could not observe the fix. We mount +/// the minimum theme/kernel tree under a tight, [Align]ed [SizedBox] so the +/// `Flexible` gets a real bounded width from the surrounding Row. +Widget _narrowRow(KernelFixture f, double width, Widget child) => Directionality( + textDirection: TextDirection.ltr, + child: ClideKernel( + services: f.services, + child: ClideTheme( + controller: f.services.theme, + child: MediaQuery( + data: const MediaQueryData(), + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: width, + height: 24, + child: Row( + children: [ + Flexible(flex: 1, fit: FlexFit.loose, child: child), + const SizedBox(width: 20), // simulated right-side items + ], + ), + ), + ), + ), + ), + ), + ); + void main() { late KernelFixture f; setUp(() async => f = await KernelFixture.create()); @@ -32,4 +70,53 @@ void main() { await tester.pump(); expect(find.text('S'), findsNothing); }); + + // T-160: at a narrow bar width the slot must never overflow and + // ClideMarquee must receive a bounded (scrollable) viewport. + testWidgets('no RenderFlex overflow at narrow width — ClideMarquee is bounded', (tester) async { + // A long status line similar to what T-154 added: + // "opus 4.7 · default · 21k ctx · 10 skills" — roughly 280 px of text. + const longStatus = Text( + 'opus 4.7 · default · 21k ctx · 10 skills', + textDirection: TextDirection.ltr, + softWrap: false, + ); + + // Pump at 200 px wide — narrower than the status content so the marquee + // must receive a bounded viewport < content width and start scrolling. + const barWidth = 200.0; + await tester.pumpWidget(_narrowRow(f, barWidth, const PaneContextStatusItem())); + await tester.pump(); // first frame — nothing focused yet + + final focus = f.services.focus; + focus.setActive(slot: Slots.workspace, contributionId: 'pane.z'); + focus.setStatusWidget('pane.z', longStatus); + await tester.pump(); // status widget appears + await tester.pump(); // ClideMarquee _measure post-frame callback + + // 1. No overflow exception. + expect(tester.takeException(), isNull); + + // 2. The ClideMarquee is in the tree. + expect(find.byType(ClideMarquee), findsOneWidget); + + // 3. The ClideMarquee render box is bounded — its width must be less than + // barWidth (the slot shrinks to fit, not past the container). + final marqueeSize = tester.getSize(find.byType(ClideMarquee)); + expect(marqueeSize.width, lessThan(barWidth)); + expect(marqueeSize.width, greaterThan(0)); + + // Dispose the ticker by tearing down the widget tree. + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('no overflow when no status is active — slot is shrunk', (tester) async { + const barWidth = 200.0; + await tester.pumpWidget(_narrowRow(f, barWidth, const PaneContextStatusItem())); + await tester.pump(); + + // No focused pane → SizedBox.shrink path. No overflow, no marquee. + expect(tester.takeException(), isNull); + expect(find.byType(ClideMarquee), findsNothing); + }); }