diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index 305686fd..a6290859 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2194,3 +2194,5 @@ Acceptance: a secondary tab shows its OWN fresh conversation, independent of the 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-146', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 06:19:51', '2026-05-23 06:19:51', '2026-05-23 06:19:51', NULL, 'dd4ec6cfbbc3118cd4cf02604b91108b', 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-146', 'status', 'in_progress', 'done', NULL, '2026-05-23 06:30:47', '2026-05-23 06:30:47', '2026-05-23 06:30:47', NULL, '93f9c6814b9e82e5b60ac0e57fff5afe', 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-147', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 07:03:18', '2026-05-23 07:03:18', '2026-05-23 07:03:18', NULL, 'b0bc1949f98ef05a05717cef80ba194f', 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-147', 'status', 'in_progress', 'done', NULL, '2026-05-23 07:06:54', '2026-05-23 07:06:54', '2026-05-23 07:06:54', NULL, 'b6b88954fdc04ecf7847dea204a92e14', 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-149', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 07:14:36', '2026-05-23 07:14:36', '2026-05-23 07:14:36', NULL, '75bb1fa2d964f284910201b1c15995b9', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 744d169c..277247a4 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2552,3 +2552,5 @@ Acceptance: a secondary tab shows its OWN fresh conversation, independent of the ', 'done', 'high', NULL, NULL, NULL, '2026-05-23 06:19:51', '2026-05-23 06:30:47', NULL, '2e1843c9b540a3d7ced03052d9efcbc4', 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-147', 'bug', 'T-132', 'Claude pane gets stuck waiting on a stale/legacy session', 'When the bound transcript (/.jsonl) never appears — e.g. attaching via new-session -A to a session created before --session-id binding (T-146), or any unconnectable session — the pane shows ''Waiting for Claude…'' forever. Fix: before spawning, if the bound transcript for our deterministic session id does not exist, kill the clide tmux session of that exact name so new-session creates a clean session with our --session-id. SAFETY (hard requirement): only ever kill clide-initiated sessions — by exact clide-claude- name, on the private -L clide socket (a user''s terminal claude is never on that socket); never delete any transcript file. Also makes clean-install/first-run robust. Live-verify the stuck primary self-heals on relaunch and a secondary still gets a fresh independent session.', 'in_progress', 'high', NULL, NULL, NULL, '2026-05-23 07:03:18', '2026-05-23 07:03:18', NULL, '21f7ebec0ddcab48a3ea7e8be0c0e979', 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-148', 'task', 'T-132', 'Claude transcript disk-usage indicator + cleanup button', 'Surface transcript/session disk usage in the Claude panel with a cleanup affordance so the user can pick and choose what to remove. Per-session sizes (the /*.jsonl files + subagents/), a total, and a button to delete selected sessions'' transcripts. User-driven only — clide never auto-deletes transcript content. Future/''at some point'' per user. Relates to T-146 (session ids) and D-75.', 'backlog', 'low', NULL, NULL, NULL, '2026-05-23 07:03:18', '2026-05-23 07:03:18', NULL, '7bbe7f4baf80027516cc46cff93a8dfe', 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-147', 'bug', 'T-132', 'Claude pane gets stuck waiting on a stale/legacy session', 'When the bound transcript (/.jsonl) never appears — e.g. attaching via new-session -A to a session created before --session-id binding (T-146), or any unconnectable session — the pane shows ''Waiting for Claude…'' forever. Fix: before spawning, if the bound transcript for our deterministic session id does not exist, kill the clide tmux session of that exact name so new-session creates a clean session with our --session-id. SAFETY (hard requirement): only ever kill clide-initiated sessions — by exact clide-claude- name, on the private -L clide socket (a user''s terminal claude is never on that socket); never delete any transcript file. Also makes clean-install/first-run robust. Live-verify the stuck primary self-heals on relaunch and a secondary still gets a fresh independent session.', 'done', 'high', NULL, NULL, NULL, '2026-05-23 07:03:18', '2026-05-23 07:06:54', NULL, '4c08227959a8fe6905521ebdc42487c1', 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-149', 'task', 'T-132', 'Native Claude pane startup banner (empty state)', 'Replace the bland ''Waiting for Claude…'' empty state with a native clide banner: clide logo, a Claude label (brand orange), the session role (primary/secondary N), the workspace path (home-collapsed), and the tmux session/status line, plus a ''warming up'' hint. Owned/native — no tmux capture-pane, no CC-internals coupling. ConversationView gains an optional emptyState widget; ClaudePane supplies a ClaudeBanner built from data it already has (repoRoot, statusLine, isPrimary).', 'in_progress', 'low', NULL, NULL, NULL, '2026-05-23 07:14:36', '2026-05-23 07:14:36', NULL, '741b021184df1596b364aa2d09167e85', 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 07ad9ce2..cb0824e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Changed +- The empty Claude pane now shows a native startup banner — clide logo, + session role, workspace, and tmux status — instead of a bare "Waiting + for Claude…" (T-149). - User and Claude turns in the conversation now render as distinct accent-striped cards over a filled background — your prompts in the theme focus colour, Claude's replies in Claude's brand orange diff --git a/assets/licenses.yaml b/assets/licenses.yaml index 6772a9ce..5e78ca19 100644 --- a/assets/licenses.yaml +++ b/assets/licenses.yaml @@ -226,4 +226,20 @@ dev_dependencies: license: BSD-3-Clause purpose: >- Dart test runner for the Flutter-free PTY tests under - `dart test --tags forkpty` (flutter_test is used elsewhere). + `dart test --tags pty` (flutter_test is used elsewhere). + +# Trademark / brand notices — third-party marks clide references but does +# NOT bundle. No artefact ships for these; tracked here for audit +# completeness and to record the nominative-use basis. +trademark_notices: + - mark: Claude name + accent colour (#d97757) + owner: Anthropic, PBC + reference: https://github.com/anthropics/skills/tree/main/skills/brand-guidelines + usage: >- + Nominative use only. "Claude" identifies the CLI that clide + integrates with; #d97757 is Anthropic's published Claude accent + colour, used solely to mark Claude's own messages/labels in the + conversation UI (not as clide's brand colour). clide is not + affiliated with, endorsed by, or partnered with Anthropic, bundles + no Anthropic logo or brand artwork, and ships its own logo. CLAUDE + is a registered trademark of Anthropic, PBC. diff --git a/lib/builtin/claude/src/claude_banner.dart b/lib/builtin/claude/src/claude_banner.dart new file mode 100644 index 00000000..00046c0f --- /dev/null +++ b/lib/builtin/claude/src/claude_banner.dart @@ -0,0 +1,65 @@ +/// Native startup banner for the empty Claude pane (T-149). +/// +/// Shown by [ConversationView] before any transcript items arrive — a +/// clide-branded placeholder (clide's own logo) that orients the user: +/// which session, which workspace, what status. The "Claude" label uses +/// Anthropic's published accent colour ([claudeAccent], #d97757) purely +/// to identify Claude — nominative use; clide bundles no Anthropic +/// artwork (see assets/licenses.yaml trademark_notices). +library; + +import 'dart:io' show Platform; + +import 'package:clide/builtin/claude/src/conversation_view.dart' show claudeAccent; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; + +class ClaudeBanner extends StatelessWidget { + const ClaudeBanner({super.key, required this.role, this.workspace, this.statusLine}); + + /// Session role, e.g. `'primary'` or `'secondary 2'`. + final String role; + + /// Repo root for this session (shown home-collapsed). + final String? workspace; + + /// Status line, e.g. `'tmux · clide-claude-…'`. + final String? statusLine; + + @override + Widget build(BuildContext context) { + final ws = _collapseHome(workspace); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const ClideSvgView.asset('assets/logo/logo.svg', width: 60, height: 60), + const SizedBox(height: 18), + ClideText( + 'Claude', + fontSize: clideFontDialogTitle, + color: claudeAccent, + fontWeight: FontWeight.w500, + ), + const SizedBox(height: 2), + ClideText(role, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily), + const SizedBox(height: 16), + if (ws != null) ClideText(ws, fontSize: clideFontCaption, muted: true), + if (statusLine != null) ...[ + const SizedBox(height: 2), + ClideText(statusLine!, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily), + ], + const SizedBox(height: 16), + const ClideText('Warming up — your conversation will appear here.', fontSize: clideFontSmall, muted: true), + ], + ), + ); + } + + static String? _collapseHome(String? path) { + if (path == null) return null; + final home = Platform.environment['HOME'] ?? ''; + if (home.isNotEmpty && path.startsWith(home)) return '~${path.substring(home.length)}'; + return path; + } +} diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index fe78cdcd..9647641d 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -7,6 +7,7 @@ import 'package:clide/widgets/widgets.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/widgets.dart'; +import 'claude_banner.dart'; import 'claude_composer.dart'; import 'clipboard_paste.dart'; import 'conversation_controller.dart'; @@ -46,6 +47,7 @@ class _ClaudePaneState extends State { String? _paneId; String? _sessionName; String? _sessionId; + String? _repoRoot; String? _error; String _statusLine = 'attaching…'; @@ -142,6 +144,7 @@ class _ClaudePaneState extends State { if (rootResp.ok) { repoRoot = (rootResp.data['path'] as String?) ?? repoRoot; } + _repoRoot = repoRoot; _sessionName = widget.isPrimary ? primarySessionName(repoRoot) : secondarySessionName(repoRoot, widget.secondaryIndex!); // Bind this pane to a specific Claude session id so concurrent @@ -310,7 +313,16 @@ class _ClaudePaneState extends State { } else if (_conversation != null) { body = Column( children: [ - Expanded(child: ConversationView(controller: _conversation!)), + Expanded( + child: ConversationView( + controller: _conversation!, + emptyState: ClaudeBanner( + role: widget.isPrimary ? 'primary' : 'secondary ${widget.secondaryIndex}', + workspace: _repoRoot, + statusLine: _statusLine, + ), + ), + ), ClaudeComposer( enabled: _paneId != null, onSubmit: _send, diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index 33593b3a..f15510d1 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -23,6 +23,7 @@ class ConversationView extends StatefulWidget { super.key, required this.controller, this.wrapInSelectionArea = true, + this.emptyState, }); final ConversationController controller; @@ -32,6 +33,10 @@ class ConversationView extends StatefulWidget { /// selection spans tiles — nesting SelectionAreas is illegal (T-140). final bool wrapInSelectionArea; + /// Shown while there are no items yet (e.g. the [ClaudeBanner] startup + /// banner). Defaults to a plain "Waiting for Claude…" hint. + final Widget? emptyState; + @override State createState() => _ConversationViewState(); } @@ -80,9 +85,7 @@ class _ConversationViewState extends State { if (items.isEmpty) { return ColoredBox( color: tokens.panelBackground, - child: const Center( - child: ClideText('Waiting for Claude…', muted: true), - ), + child: widget.emptyState ?? const Center(child: ClideText('Waiting for Claude…', muted: true)), ); } diff --git a/test/builtin/claude/conversation_view_test.dart b/test/builtin/claude/conversation_view_test.dart index 14af3de5..1c07fa0f 100644 --- a/test/builtin/claude/conversation_view_test.dart +++ b/test/builtin/claude/conversation_view_test.dart @@ -6,6 +6,7 @@ library; import 'dart:async'; +import 'package:clide/builtin/claude/src/claude_banner.dart'; import 'package:clide/builtin/claude/src/conversation_controller.dart'; import 'package:clide/builtin/claude/src/conversation_view.dart'; import 'package:clide/builtin/claude/src/transcript_publisher.dart'; @@ -137,6 +138,21 @@ void main() { expect(find.text('Waiting for Claude…'), findsOneWidget); }); + testWidgets('empty controller shows the provided emptyState instead', (tester) async { + final stream = StreamController.broadcast(); + final c = ConversationController(stream: stream.stream); + addTearDown(c.dispose); + await tester.pumpWidget(harness( + f, + ConversationView( + controller: c, + emptyState: const ClideText('CUSTOM EMPTY'), + ), + )); + expect(find.text('CUSTOM EMPTY'), findsOneWidget); + expect(find.text('Waiting for Claude…'), findsNothing); + }); + testWidgets('renders a card per item kind with role/tool labels', (tester) async { await pumpWith(tester, [ _user('a question'), @@ -183,4 +199,27 @@ void main() { expect(copied, contains('answer text')); }); }); + + group('ClaudeBanner', () { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + testWidgets('shows role, workspace, status, and a hint', (tester) async { + await tester.pumpWidget(harness( + f, + const ClaudeBanner( + role: 'primary', + workspace: '/work/space', + statusLine: 'tmux · clide-claude-x', + ), + )); + await tester.pump(); + expect(find.text('Claude'), findsOneWidget); + expect(find.text('primary'), findsOneWidget); + expect(find.text('/work/space'), findsOneWidget); + expect(find.text('tmux · clide-claude-x'), findsOneWidget); + expect(find.textContaining('Warming up'), findsOneWidget); + }); + }); }