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
@@ -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;
+6
View File
@@ -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 <id>.jsonl (and sidecar <id>/ 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 <id>.jsonl (and sidecar <id>/ 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);
+6
View File
@@ -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)
+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() {
@@ -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 <projectDir>/<id>.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';