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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user