primary /clear empties the deterministic session in place (T-268)

The primary Claude pane is anchored to a deterministic session id derived
from the repo path so it resumes the same transcript across restarts
(D-77/T-146). But /clear (T-156) respawned on a fresh RANDOM id, so the
next launch re-resolved to the deterministic id, found its old transcript
on disk, and resumed the PRE-clear conversation — the cleared session was
orphaned and the clear silently didn't stick.

/clear in the primary pane now deletes the deterministic session's
transcript (and its sidecar dir) and respawns on the SAME id, so
`--session-id` re-creates it empty and a cleared primary stays cleared.
Secondary panes are throwaway and keep the fresh-random behaviour.

Factor the duplicated transcript-path construction out of claude_pane into
session_naming helpers (claudeProjectDir / claudeTranscriptPath /
clearSessionTranscript) so the clear logic is DRY and unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 11:39:26 +02:00
co-authored by Claude Opus 4.8
parent 9905854fca
commit 892240b3a6
6 changed files with 123 additions and 8 deletions
+26 -7
View File
@@ -236,8 +236,7 @@ class _ClaudePaneState extends State<ClaudePane> {
// A transcript already on disk means the session existed before, so resume
// it; `claude --session-id <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<ClaudePane> {
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<void> _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<ClaudePane> {
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<String>(
@@ -367,11 +379,18 @@ class _ClaudePaneState extends State<ClaudePane> {
}
/// Tear the current session down and respawn bound to [sessionId]. The old
/// process is killed via the orchestrator; its transcript stays on disk.
Future<void> _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<void> _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;
+32 -1
View File
@@ -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<String> claudeLaunchArgs(String sessionId, {required bool resume}) => resum
/// `init` event.
List<String> 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/<munged-repo-root>`, 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 <sessionId>` re-creates it empty — the in-place
/// `/clear` path for the primary pane (T-268). Removes both the `<id>.jsonl`
/// and the sidecar `<id>/` 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<void> 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() {