add a native startup banner to the empty Claude pane (T-149)

Replaces the bare "Waiting for Claude…" empty state with ClaudeBanner:
clide's own logo, a "Claude" label, the session role (primary /
secondary N), the workspace (home-collapsed), the tmux status line, and
a warming-up hint. ConversationView gains an optional emptyState widget;
the pane supplies the banner from data it already has.

Fully owned — no tmux capture-pane, no Anthropic artwork. The "Claude"
label uses Anthropic's published accent #d97757 nominatively; recorded
under a new trademark_notices section in assets/licenses.yaml (clide is
unaffiliated, bundles no Anthropic logo/artwork). Also fixes a stale
forkpty->pty reference in that file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 09:26:07 +02:00
co-authored by Claude Opus 4.7
parent b52283b7c0
commit af483c2c34
8 changed files with 147 additions and 5 deletions
@@ -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;
+2
View File
@@ -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 (<munged-cwd>/<session-id>.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-<slug> 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 <munged-cwd>/*.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 (<munged-cwd>/<session-id>.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-<slug> 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);
+3
View File
@@ -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
+17 -1
View File
@@ -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.
+65
View File
@@ -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;
}
}
+13 -1
View File
@@ -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<ClaudePane> {
String? _paneId;
String? _sessionName;
String? _sessionId;
String? _repoRoot;
String? _error;
String _statusLine = 'attaching…';
@@ -142,6 +144,7 @@ class _ClaudePaneState extends State<ClaudePane> {
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<ClaudePane> {
} 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,
@@ -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<ConversationView> createState() => _ConversationViewState();
}
@@ -80,9 +85,7 @@ class _ConversationViewState extends State<ConversationView> {
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)),
);
}
@@ -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<ConversationItem>.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);
});
});
}