fix Claude secondary pane copy + false "session exited"

Two things surfaced in the secondary pane: the tab said "session 1"
while the banner said "secondary 1" — now both say "session N". And the
banner showed "session exited" right after starting, even though Claude
was alive: a transient tmux client process can exit during spawn while
the session itself is fine. pane.exit now verifies via `tmux has-session`
and only reports exited when the session is actually gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 09:48:26 +02:00
co-authored by Claude Opus 4.7
parent af483c2c34
commit f65ef50379
6 changed files with 34 additions and 2 deletions
@@ -2196,3 +2196,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
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;
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', 'in_progress', 'done', NULL, '2026-05-23 07:27:32', '2026-05-23 07:27:32', '2026-05-23 07:27:32', NULL, 'd020ddfea99355676800af1e39249c85', 1) ON CONFLICT(hash) DO NOTHING;
+1
View File
@@ -2554,3 +2554,4 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority,
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);
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).', 'done', 'low', NULL, NULL, NULL, '2026-05-23 07:14:36', '2026-05-23 07:27:32', NULL, '1d922f79ed8f98bea75042af8662dd74', 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);
+4
View File
@@ -123,6 +123,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Fixed
- Claude secondary panes no longer flash a false "session exited" while
the session is alive (a transient tmux client exit is now verified
against the live session), and the tab and banner agree on the label
("session N") (T-149).
- Claude pane no longer gets stuck on "Waiting for Claude…" when its
session can't be bound (T-147) — e.g. a session left over from before
session-id binding, or a fresh machine. It now retires that stale
+10 -2
View File
@@ -275,10 +275,18 @@ class _ClaudePaneState extends State<ClaudePane> {
final kernel = _kernel();
if (kernel == null) return;
// Lifecycle only — content comes from the transcript, not pane.output.
_eventSub = kernel.events.on<DaemonEvent>().listen((e) {
_eventSub = kernel.events.on<DaemonEvent>().listen((e) async {
if (e.subsystem != 'pane' || e.data['id'] != _paneId) return;
switch (e.kind) {
case 'pane.exit':
// A transient tmux client can exit (e.g. during spawn/respawn)
// while the session — and Claude — stay alive. Don't report
// "exited" then; only when the tmux session is actually gone.
// (The no-tmux fallback has no session, so the exit is real.)
if (_usingTmux && _sessionName != null && await tmux.hasSession(_sessionName!)) {
return;
}
if (!mounted) return;
setState(() => _statusLine = widget.isPrimary ? 'session exited — restart clide to retry' : 'session exited');
case 'pane.closed':
_paneId = null;
@@ -317,7 +325,7 @@ class _ClaudePaneState extends State<ClaudePane> {
child: ConversationView(
controller: _conversation!,
emptyState: ClaudeBanner(
role: widget.isPrimary ? 'primary' : 'secondary ${widget.secondaryIndex}',
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
workspace: _repoRoot,
statusLine: _statusLine,
),
+8
View File
@@ -24,6 +24,14 @@ Future<void> killSession(String name) async {
await tmuxRunner([..._socket, 'kill-session', '-t', name]);
}
/// Whether [name] is a live session on the clide socket. Used to suppress
/// a spurious "session exited" when a transient tmux client process exits
/// but the session itself is fine (T-149 follow-up).
Future<bool> hasSession(String name) async {
final r = await tmuxRunner([..._socket, 'has-session', '-t', name]);
return r.exitCode == 0;
}
/// Return the names of all sessions currently alive on the clide
/// socket. Empty list if the server is not running.
Future<List<String>> listClideSessions() async {
@@ -104,6 +104,16 @@ void main() {
});
});
group('hasSession', () {
test('true on exit 0, false otherwise', () async {
runner.exitCode = 0;
expect(await tmux.hasSession('clide-claude-foo'), isTrue);
expect(runner.calls.last, ['-L', 'clide', 'has-session', '-t', 'clide-claude-foo']);
runner.exitCode = 1;
expect(await tmux.hasSession('clide-claude-foo'), isFalse);
});
});
group('sendMessage', () {
test('loads a bracketed paste buffer then submits with Enter', () async {
await tmux.sendMessage('clide-claude-foo', 'hello\nworld');