give the status-bar context slot a flexible share

StatusbarHost laid every item at intrinsic width, so once the focused-pane
context line grew long (a model/mode/context/skills summary) the row's
content exceeded the bar width and overflowed instead of letting the slot
shrink. Add an opt-in flex factor to StatusItemContribution; the host wraps
flex>0 items in Flexible(loose) so they yield width when the bar is tight,
and the marquee then receives a bounded viewport and scrolls. Drops the
fixed maxWidth cap on the pane-context item.

T-160.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-30 13:04:59 +02:00
co-authored by Claude
parent 4761b5c12d
commit 7b9861a097
7 changed files with 144 additions and 12 deletions
@@ -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;
+27
View File
@@ -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<String, AssistantToolUse> 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.<model>.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 <uuid>; resume an existing one: --resume <id> (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);
+3
View File
@@ -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
+7 -1
View File
@@ -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),
],
@@ -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),
),
);
},
+8
View File
@@ -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.
@@ -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);
});
}