diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 2b40777d..442fd956 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -763,3 +763,4 @@ Refs: D-78 (interaction zone / display-only conversation widgets). Built on T-16 SEQUENCING UPDATE (after T-266 refinement): the shared container/holder primitive now lives in T-266 and is CONSUMED by T-264 (nested agent run), so T-266 is no longer "last" — its primitive lands before/with T-264. T-264 is now blocked by T-266. Revised order: T-262 / T-263 (fold success result, fold agent prompt) → T-266 (shared holder/container primitive + activity-card restyle) → T-264 (nest the whole agent run on that primitive) → T-265 (relabel sidechain prose) can land anytime alongside.', NULL, '2026-06-07 08:49:16', '2026-06-07 08:49:16', '2026-06-07 08:49:16', NULL, '7795b980e57c7bf096b495b3f61c8289', 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-255', 'status', 'in_progress', 'done', NULL, '2026-06-07 08:51:08', '2026-06-07 08:51:08', '2026-06-07 08:51:08', NULL, '68cf2ebb539ca57c1c8165acecf3c111', 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-268', 'status', 'backlog', 'done', NULL, '2026-06-07 09:36:59', '2026-06-07 09:36:59', '2026-06-07 09:36:59', NULL, 'dcce34ebf831a8836fe31bcad6bab92c', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 688c2a3e..2bc7983a 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -1591,3 +1591,9 @@ list, ship our own curated, on-brand list instead. Document the decision. - Add a widget/golden test for the animated states (bounded pumps — no real timers). Refinement (2026-06-07): open question resolved — ship OUR OWN curated verb list, do not reuse the CLI''s. Rationale: the spinner words are a TUI cosmetic not surfaced by the stream-json control protocol (so there''s nothing to read live), and extracting Anthropic''s bundled list is a licensing gray area. A clide-owned list aligns with ''own the rendering stack'' and D-75 (isolate/version-pin CC coupling). State lives in the WIDGET layer (a RunningIndicator in lib/builtin/claude/), not the orchestrator — it''s ephemeral UI. Animation via AnimationController (no Timers, so tests use bounded pumps); reduced-motion (MediaQuery.disableAnimations) shows a static verb. Ready to implement.', 'done', 'medium', NULL, NULL, NULL, '2026-06-06 09:56:23', '2026-06-07 08:51:08', NULL, '2e77a2ef6235bc9f450e53c91d430b3e', 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-268', 'bug', NULL, 'primary /clear must clear the deterministic session in place, not orphan to a fresh id', 'The primary Claude pane is anchored to a deterministic session id derived from the repo path (primarySessionId), so it resumes the same transcript across restarts (D-77/T-146). But clide-owned /clear (T-156) respawns on a fresh RANDOM id (freshSessionId). Next clide launch recomputes the deterministic id, finds its old transcript on disk, and resumes the PRE-clear conversation — the cleared session is orphaned. Continuity appears broken. + +Fix: /clear in the PRIMARY pane should empty the deterministic session in place — delete its .jsonl (and sidecar / dir) so ''claude --session-id'' re-creates it empty, then respawn on the SAME deterministic id. Secondary panes are throwaway and keep the fresh-random behavior. Factor the transcript path/dir construction (duplicated in claude_pane._spawn and _resumeFlow) into session_naming helpers (claudeProjectDir/claudeTranscriptPath/clearSessionTranscript) so the clear logic is DRY and unit-testable.', 'backlog', 'high', NULL, NULL, 'D-77', '2026-06-07 09:35:03', '2026-06-07 09:35:03', NULL, 'a4508cc5c3e4d9aaaca886e7076b3f80', 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-268', 'bug', NULL, 'primary /clear must clear the deterministic session in place, not orphan to a fresh id', 'The primary Claude pane is anchored to a deterministic session id derived from the repo path (primarySessionId), so it resumes the same transcript across restarts (D-77/T-146). But clide-owned /clear (T-156) respawns on a fresh RANDOM id (freshSessionId). Next clide launch recomputes the deterministic id, finds its old transcript on disk, and resumes the PRE-clear conversation — the cleared session is orphaned. Continuity appears broken. + +Fix: /clear in the PRIMARY pane should empty the deterministic session in place — delete its .jsonl (and sidecar / dir) so ''claude --session-id'' re-creates it empty, then respawn on the SAME deterministic id. Secondary panes are throwaway and keep the fresh-random behavior. Factor the transcript path/dir construction (duplicated in claude_pane._spawn and _resumeFlow) into session_naming helpers (claudeProjectDir/claudeTranscriptPath/clearSessionTranscript) so the clear logic is DRY and unit-testable.', 'done', 'high', NULL, NULL, 'D-77', '2026-06-07 09:35:03', '2026-06-07 09:36:59', NULL, '824de390dc6da465575d998291976377', 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 a0be1004..f3ac42bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -154,6 +154,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Fixed +- `/clear` in the primary Claude pane now clears that session **in place** — + it empties the pane's deterministic, restart-stable session instead of + starting a throwaway random one. Previously a cleared primary was orphaned: + the next launch re-resolved to the deterministic id and resumed the + pre-clear conversation, so the clear silently didn't stick. Secondary panes + keep their fresh-session behaviour. (T-268) - In Vim mode, Esc in insert/visual mode returns to normal mode instead of closing the editor. The global "exit focus / close editor" Esc binding now stands down while Vim is in insert or visual mode. (T-257) diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index eca51854..18be7750 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -236,8 +236,7 @@ class _ClaudePaneState extends State { // A transcript already on disk means the session existed before, so resume // it; `claude --session-id ` refuses an existing id (T-161/D-77). - final home = Platform.environment['HOME'] ?? ''; - final transcriptFile = '$home/.claude/projects/${repoRoot.replaceAll('/', '-')}/$_sessionId.jsonl'; + final transcriptFile = claudeTranscriptPath(repoRoot, _sessionId!); final resume = await File(transcriptFile).exists(); try { @@ -338,9 +337,23 @@ class _ClaudePaneState extends State { widget.onFork?.call(sourceId); } - /// clide-owned `/clear` (T-156): respawn on a brand-new, empty session. + /// clide-owned `/clear` (T-156). + /// + /// The primary pane is anchored to a deterministic session id that resumes + /// across restarts (D-77/T-146), so clearing it must empty THAT session in + /// place: delete its transcript and respawn fresh on the SAME id. Spawning a + /// throwaway random id instead would orphan the cleared state — the next + /// launch would re-resolve to the deterministic id and resume the pre-clear + /// conversation, which is exactly the continuity break this fixes (T-268). + /// Secondary panes are throwaway sessions, so for them a fresh random id is + /// the clear. Future _clearSession() async { if (mounted) setState(() => _statusLine = 'clearing…'); + final root = _repoRoot; + if (widget.isPrimary && root != null) { + await _respawnWithSession(primarySessionId(root), clearTranscript: true); + return; + } await _respawnWithSession(freshSessionId()); } @@ -350,8 +363,7 @@ class _ClaudePaneState extends State { final root = _repoRoot; final dialog = _kernel()?.dialog; if (root == null || dialog == null) return; - final home = Platform.environment['HOME'] ?? ''; - final dir = Directory('$home/.claude/projects/${root.replaceAll('/', '-')}'); + final dir = Directory(claudeProjectDir(root)); final sessions = await listSessions(dir); if (!mounted) return; final picked = await dialog.show( @@ -367,11 +379,18 @@ class _ClaudePaneState extends State { } /// Tear the current session down and respawn bound to [sessionId]. The old - /// process is killed via the orchestrator; its transcript stays on disk. - Future _respawnWithSession(String sessionId) async { + /// process is killed via the orchestrator; its transcript stays on disk + /// unless [clearTranscript] is set, in which case [sessionId]'s transcript is + /// erased after the kill so `--session-id` re-creates it empty (T-268). + Future _respawnWithSession(String sessionId, {bool clearTranscript = false}) async { _statusSub?.cancel(); _statusSub = null; await activeSessionOrchestrator?.close(_orchId); // kills the old session + // Erase only after the process is dead, so claude isn't mid-write. + final root = _repoRoot; + if (clearTranscript && root != null) { + await clearSessionTranscript(claudeProjectDir(root), sessionId); + } _conversation = null; _session = null; _sessionId = sessionId; diff --git a/lib/builtin/claude/src/session_naming.dart b/lib/builtin/claude/src/session_naming.dart index b15226b0..62d62cc5 100644 --- a/lib/builtin/claude/src/session_naming.dart +++ b/lib/builtin/claude/src/session_naming.dart @@ -12,7 +12,7 @@ library; import 'dart:convert'; -import 'dart:io' show Platform; +import 'dart:io' show Directory, File, Platform; import 'dart:math'; // --------------------------------------------------------------------------- @@ -82,6 +82,37 @@ List claudeLaunchArgs(String sessionId, {required bool resume}) => resum /// `init` event. List forkSessionArgs(String sourceSessionId) => ['--resume', sourceSessionId, '--fork-session']; +// --------------------------------------------------------------------------- +// Transcript locations on disk (T-161 / T-268) +// --------------------------------------------------------------------------- + +/// The directory claude stores [repoRoot]'s transcripts in: +/// `~/.claude/projects/`, where the munge replaces `/` with +/// `-`. Matches claude's own project-dir naming and is the single source of +/// truth for the path that [ClaudePane] both probes (resume vs create) and +/// lists (`/resume` picker). +String claudeProjectDir(String repoRoot) { + final home = Platform.environment['HOME'] ?? ''; + return '$home/.claude/projects/${repoRoot.replaceAll('/', '-')}'; +} + +/// The transcript JSONL path for [sessionId] under [repoRoot]. Its existence is +/// what decides `--resume` vs `--session-id` (T-161). +String claudeTranscriptPath(String repoRoot, String sessionId) => '${claudeProjectDir(repoRoot)}/$sessionId.jsonl'; + +/// Erase [sessionId]'s transcript under [projectDir] so a subsequent +/// `claude --session-id ` re-creates it empty — the in-place +/// `/clear` path for the primary pane (T-268). Removes both the `.jsonl` +/// and the sidecar `/` directory claude keeps beside it. Best-effort: +/// missing entries are not an error. The caller MUST have killed the session's +/// process first, so claude is not mid-write. +Future clearSessionTranscript(String projectDir, String sessionId) async { + final file = File('$projectDir/$sessionId.jsonl'); + if (await file.exists()) await file.delete(); + final dir = Directory('$projectDir/$sessionId'); + if (await dir.exists()) await dir.delete(recursive: true); +} + /// A fresh random session id for a secondary pane — secondaries are /// always clean sessions, never resumed. String freshSessionId() { diff --git a/test/builtin/claude/session_naming_test.dart b/test/builtin/claude/session_naming_test.dart index 35aa00c6..b50e10f0 100644 --- a/test/builtin/claude/session_naming_test.dart +++ b/test/builtin/claude/session_naming_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:clide/builtin/claude/src/session_naming.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -53,6 +55,56 @@ void main() { }); }); + group('transcript locations (T-161 / T-268)', () { + test('project dir munges the repo root and lives under ~/.claude/projects', () { + final dir = claudeProjectDir('/var/mnt/data/projects/clide'); + expect(dir, endsWith('/.claude/projects/-var-mnt-data-projects-clide')); + }); + + test('transcript path is /.jsonl', () { + const id = '11111111-1111-4111-8111-111111111111'; + final path = claudeTranscriptPath('/var/mnt/data/projects/clide', id); + expect(path, '${claudeProjectDir('/var/mnt/data/projects/clide')}/$id.jsonl'); + }); + }); + + group('clearSessionTranscript (T-268)', () { + const id = '22222222-2222-4222-8222-222222222222'; + + test('deletes the transcript jsonl and its sidecar dir, leaving others', () async { + final tmp = await Directory.systemTemp.createTemp('clide-clear-'); + try { + final jsonl = File('${tmp.path}/$id.jsonl'); + await jsonl.writeAsString('{"type":"user"}\n'); + final sidecar = Directory('${tmp.path}/$id'); + await sidecar.create(); + await File('${sidecar.path}/notes.json').writeAsString('{}'); + // A different session's transcript must survive the clear. + final other = File('${tmp.path}/33333333-3333-4333-8333-333333333333.jsonl'); + await other.writeAsString('{}'); + + await clearSessionTranscript(tmp.path, id); + + expect(await jsonl.exists(), isFalse); + expect(await sidecar.exists(), isFalse); + expect(await other.exists(), isTrue, reason: 'only the cleared session is erased'); + } finally { + await tmp.delete(recursive: true); + } + }); + + test('is a no-op when nothing is on disk (best-effort)', () async { + final tmp = await Directory.systemTemp.createTemp('clide-clear-'); + try { + // Must not throw even though neither the jsonl nor the dir exist. + await clearSessionTranscript(tmp.path, id); + expect(await Directory(tmp.path).list().isEmpty, isTrue); + } finally { + await tmp.delete(recursive: true); + } + }); + }); + group('forkSessionArgs (T-172)', () { const sourceId = 'aaaa1111-1111-4111-8111-111111111111';