From 8907abeca7571fa69677ad53622a4e14485437f2 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 23 May 2026 17:02:20 +0200 Subject: [PATCH] add Claude session storage view with user-driven cleanup The claude.session-storage command opens a modal listing the workspace's session transcripts with their on-disk sizes (the .jsonl plus the / subagents dir) and a total. Each row deletes with a deliberate two-click confirm; deletion is guarded against unsafe ids and clide never removes transcripts on its own. SessionSummary gains a sizeBytes field and session_index gains formatBytes + deleteSession. T-148. Co-Authored-By: Claude Opus 4.7 (1M context) --- .pql/changelog/ticket_history/2026-05.sql | 4 + .pql/changelog/tickets/2026-05.sql | 19 ++ CHANGELOG.md | 4 + lib/builtin/claude/src/extension.dart | 26 +++ lib/builtin/claude/src/session_index.dart | 42 ++++- lib/builtin/claude/src/session_storage.dart | 164 ++++++++++++++++++ test/builtin/claude/session_index_test.dart | 45 +++++ test/builtin/claude/session_storage_test.dart | 74 ++++++++ 8 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 lib/builtin/claude/src/session_storage.dart create mode 100644 test/builtin/claude/session_storage_test.dart diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index a7c7c88c..f54fe37a 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2211,3 +2211,7 @@ 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-156', 'status', 'in_progress', 'done', NULL, '2026-05-23 12:05:19', '2026-05-23 12:05:19', '2026-05-23 12:05:19', NULL, '24db72624201c89a5c0bcb3b11840275', 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-154', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 14:40:01', '2026-05-23 14:40:01', '2026-05-23 14:40:01', NULL, 'aff6dbb3095efd46cf786a53d8622d42', 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-155', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 14:40:05', '2026-05-23 14:40:05', '2026-05-23 14:40:05', NULL, 'e46f5eba5bd678190954744c2f8338bc', 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-154', 'status', 'in_progress', 'done', NULL, '2026-05-23 14:50:17', '2026-05-23 14:50:17', '2026-05-23 14:50:17', NULL, '121736159d2d58380447b6a2803cb80b', 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-155', 'status', 'in_progress', 'done', NULL, '2026-05-23 14:50:17', '2026-05-23 14:50:17', '2026-05-23 14:50:17', NULL, 'b471e923f78ed91f05ca6f2dcbd5c85c', 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-148', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 14:51:55', '2026-05-23 14:51:55', '2026-05-23 14:51:55', NULL, '3002f91cea5919aaefc3e4bc530c7ac2', 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-141', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 14:51:55', '2026-05-23 14:51:55', '2026-05-23 14:51:55', NULL, '76975080bd78de4d6f69937fd256d4ef', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 88a8d04e..08a3f975 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2622,3 +2622,22 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, Proper fix: when an extension activates, auto-load the i18nNamespace(s) declared by its contributions via i18n.ensureNamespaceLoaded — removing the manual _tier0Namespaces list and the drift it causes. AssetCatalogLoader.load returns empty for a missing file, so auto-preloading alone would just shift the warning to ''missing key tab.title'' — so the missing catalogs must also be created: builtin.{tickets,decisions,git,pql,problems}_en_us.json with at least tab.title plus any other keys those extensions look up. Acceptance: clean boot with all builtins active logs zero i18n warnings; a new builtin that declares a titleKey gets its namespace loaded automatically with no manual list edit; the five catalog files exist; tests cover the auto-load-on-activate path and the missing-catalog case; coverage >= floor.', 'in_progress', 'low', NULL, NULL, NULL, '2026-05-23 11:06:50', '2026-05-23 14:40:05', NULL, 'a26532f49dab8052e03654393747ce63', 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-154', 'task', 'T-132', 'Surface ClaudeConfig in the Claude status pane', 'The status surface shows LIVE model / permission-mode / context-tokens from the active transcript (T-145, T-150). Complement it with the CONFIGURED side from ClaudeConfig (T-151): available-skills count and/or configured permission/model defaults — static environment state alongside live session state. Reads from ClaudeConfig, not the filesystem; updates when config changes. Acceptance: the status surface reflects ClaudeConfig values, refreshes on config change, and keeps the live transcript-driven fields working; widget test; coverage >= floor. Blocked by T-151.', 'done', 'low', NULL, NULL, 'D-76', '2026-05-23 09:52:32', '2026-05-23 14:50:17', NULL, '0908cd711d4c826389e21c08cffe0bf6', 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-155', 'task', NULL, 'Finish i18n catalog plumbing: auto-load extension namespaces, kill ''namespace not registered'' warnings', 'Five builtins (tickets, decisions, git, pql, problems) declare titleKey: ''tab.title'' + i18nNamespace on their TabContribution but ship NO catalog file (lib/kernel/src/i18n/catalog/ has only the 8 tier-0 namespaces) and aren''t in the hand-maintained _tier0Namespaces preload list (lib/main.dart). Result: every boot logs ''i18n: namespace not registered: builtin.X (key: tab.title)'' for each. Tabs still render correctly via the placeholder fallback, so it''s cosmetic console noise — but it recurs for every new builtin because the preload list is maintained by hand. + +Proper fix: when an extension activates, auto-load the i18nNamespace(s) declared by its contributions via i18n.ensureNamespaceLoaded — removing the manual _tier0Namespaces list and the drift it causes. AssetCatalogLoader.load returns empty for a missing file, so auto-preloading alone would just shift the warning to ''missing key tab.title'' — so the missing catalogs must also be created: builtin.{tickets,decisions,git,pql,problems}_en_us.json with at least tab.title plus any other keys those extensions look up. + +Acceptance: clean boot with all builtins active logs zero i18n warnings; a new builtin that declares a titleKey gets its namespace loaded automatically with no manual list edit; the five catalog files exist; tests cover the auto-load-on-activate path and the missing-catalog case; coverage >= floor.', 'done', 'low', NULL, NULL, NULL, '2026-05-23 11:06:50', '2026-05-23 14:50:17', NULL, '5ed6f316a13c8d8bc09139b545b3fca7', 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-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 /*.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.', 'in_progress', 'low', NULL, NULL, NULL, '2026-05-23 07:03:18', '2026-05-23 14:51:55', NULL, '9f558f4aedccea6d0294faff738a38c3', 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-141', 'task', 'T-132', 'Team-meta sidebar (roster + token budget)', 'New sidebar panel (contributed to Slots.sidebar), visible only in team mode: a roster row per team member that MIRRORS the per-session status surfaced by T-145, plus team-wide meta. + +Per-member row: +- Identity from the team config + observer: name, agentType, color; membership/lifecycle from TeamMemberJoined/Left. +- Mirror of T-145''s shared status: current model, permission-mode badge (accept-edits / plan / default / bypass), context-window usage %. Consume the SAME status source/event T-145 emits — do NOT re-tail transcripts or re-parse CC internals here (D-75). +- Live status verb + elapsed-since-last-activity + down/up token deltas from that same status feed. + +Team-wide: +- Team/account token budget pulled from the Anthropic usage API on a timer (distinct from per-conversation context above). + +Acceptance: roster matches list-panes/config; each row mirrors the member''s model + accept-edits/plan badge + context-usage; status verb and token deltas update live; account budget refreshes on its timer; panel appears only when a team is active. Depends on T-145 (status source) + T-139 (done). +', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-23 14:51:55', NULL, 'e674a22d7a2c67d4fcefc12968185118', 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 a50e8ffb..8851f38f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- Claude session storage view (T-148) — the `claude.session-storage` + command opens a modal listing the workspace's session transcripts with + their on-disk sizes and a total, each removable with a two-click + confirm. User-driven only; clide never deletes transcripts on its own. - Slash-command typeahead in the Claude composer (T-152) — typing `/` (anywhere in the message, not just at the start) pops a list of matching commands and skills sourced from the Claude environment; diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index a6fcb95b..daa6b4b4 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -6,6 +6,8 @@ import 'package:clide/builtin/claude/src/claude_config.dart'; import 'package:clide/builtin/claude/src/claude_session_host.dart'; import 'package:clide/builtin/claude/src/session_naming.dart'; import 'package:clide/builtin/claude/src/pane_context_status.dart'; +import 'package:clide/builtin/claude/src/session_index.dart'; +import 'package:clide/builtin/claude/src/session_storage.dart'; import 'package:clide/builtin/claude/src/team_observer.dart'; import 'package:clide/builtin/claude/src/team_panel_host.dart'; import 'package:clide/builtin/claude/src/tmux_session.dart' as tmux; @@ -60,6 +62,12 @@ class ClaudeExtension extends ClideExtension { title: 'Claude: kill all tmux sessions for this repo', run: _killAllSessions, ), + CommandContribution( + id: 'claude.session-storage', + command: 'claude.session-storage', + title: 'Claude: session storage (disk usage + cleanup)', + run: _manageStorage, + ), // In-pane status slot (T-145): the active Claude pane publishes // its model · permission-mode · context line here. StatusItemContribution( @@ -171,6 +179,24 @@ class ClaudeExtension extends ClideExtension { return IpcResponse.ok(id: '', data: const {'status': 'killed'}); } + /// Open the session-storage manager: per-session transcript sizes, a total, + /// and a user-driven cleanup (T-148). Enumerates the workspace's sessions + /// and shows the modal; deletion happens inside the dialog. + Future _manageStorage(List args) async { + final ctx = _ctx; + if (ctx == null) return IpcResponse.ok(id: '', data: const {}); + final resp = await ctx.ipc.request('files.root'); + final root = resp.ok ? resp.data['path'] as String? : null; + final home = Platform.environment['HOME']; + if (root == null || home == null) return IpcResponse.ok(id: '', data: const {}); + final dir = Directory('$home/.claude/projects/${root.replaceAll('/', '-')}'); + final sessions = await listSessions(dir); + await ctx.dialog.show( + (c, dismiss) => SessionStorageDialog(dir: dir, sessions: sessions, onClose: dismiss), + ); + return IpcResponse.ok(id: '', data: const {'status': 'shown'}); + } + Future _primarySessionName() async { final ctx = _ctx; if (ctx == null) return null; diff --git a/lib/builtin/claude/src/session_index.dart b/lib/builtin/claude/src/session_index.dart index dab8b94b..558356b5 100644 --- a/lib/builtin/claude/src/session_index.dart +++ b/lib/builtin/claude/src/session_index.dart @@ -18,6 +18,7 @@ class SessionSummary { required this.modified, this.firstUser, this.lastUser, + this.sizeBytes = 0, }); /// The session id (the `` of `.jsonl`). @@ -29,6 +30,10 @@ class SessionSummary { final String? firstUser; final String? lastUser; + /// On-disk size of this session: the transcript `.jsonl` plus its + /// `/` subagents directory, in bytes (T-148). + final int sizeBytes; + /// "first … last" — the picker's primary label. Falls back to the id when /// the session carries no user prompt. String get label { @@ -103,17 +108,52 @@ Future> listSessions( for (final f in files) { final stat = await f.stat(); final bookends = await _bookends(f, window); + final id = _sessionId(f.path); summaries.add(SessionSummary( - id: _sessionId(f.path), + id: id, modified: stat.modified, firstUser: bookends.first, lastUser: bookends.last, + sizeBytes: stat.size + await _dirSize(Directory('${dir.path}/$id')), )); } summaries.sort((a, b) => b.modified.compareTo(a.modified)); return summaries.length > max ? summaries.sublist(0, max) : summaries; } +/// Total bytes of [dir]'s files (recursive), or 0 if it doesn't exist. Used to +/// fold a session's `/subagents/` transcripts into its reported size. +Future _dirSize(Directory dir) async { + if (!await dir.exists()) return 0; + var total = 0; + await for (final e in dir.list(recursive: true, followLinks: false)) { + if (e is File) total += await e.length(); + } + return total; +} + +/// Delete a session's transcript (`.jsonl`) and its `/` subagents +/// directory from [dir] (T-148). User-driven only — clide never calls this +/// on its own. [id] must be a bare filename component; a value with a path +/// separator or `..` is rejected to keep the delete inside [dir]. +Future deleteSession(Directory dir, String id) async { + if (id.isEmpty || id.contains('/') || id.contains(r'\') || id.contains('..')) { + throw ArgumentError('refusing to delete unsafe session id: $id'); + } + final file = File('${dir.path}/$id.jsonl'); + if (await file.exists()) await file.delete(); + final sub = Directory('${dir.path}/$id'); + if (await sub.exists()) await sub.delete(recursive: true); +} + +/// Human-readable byte size: `0 B`, `12 KB`, `3.4 MB`, `1.2 GB`. +String formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).round()} KB'; + if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; +} + String _sessionId(String path) { final base = path.split(Platform.pathSeparator).last; return base.endsWith('.jsonl') ? base.substring(0, base.length - 6) : base; diff --git a/lib/builtin/claude/src/session_storage.dart b/lib/builtin/claude/src/session_storage.dart new file mode 100644 index 00000000..fda55c8d --- /dev/null +++ b/lib/builtin/claude/src/session_storage.dart @@ -0,0 +1,164 @@ +/// Modal showing the workspace's Claude session transcripts with their +/// on-disk sizes and a user-driven cleanup (T-148). Per-row delete is a +/// deliberate two-click confirm; clide never deletes transcripts on its own. +/// No-Material (D-7); shown via the [DialogRouter]. +library; + +import 'dart:io'; + +import 'package:clide/builtin/claude/src/session_index.dart'; +import 'package:clide/builtin/claude/src/session_picker.dart' show relativeTime; +import 'package:clide/kernel/src/theme/controller.dart'; +import 'package:clide/kernel/src/theme/tokens.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +typedef SessionDeleter = Future Function(Directory dir, String id); + +class SessionStorageDialog extends StatefulWidget { + const SessionStorageDialog({ + super.key, + required this.dir, + required this.sessions, + required this.onClose, + this.deleter = deleteSession, + }); + + final Directory dir; + final List sessions; + final VoidCallback onClose; + + /// Injected so tests don't touch the real filesystem. + final SessionDeleter deleter; + + @override + State createState() => _SessionStorageDialogState(); +} + +class _SessionStorageDialogState extends State { + late final List _sessions = List.of(widget.sessions); + String? _confirmingId; + + int get _total => _sessions.fold(0, (a, s) => a + s.sizeBytes); + + Future _delete(SessionSummary s) async { + await widget.deleter(widget.dir, s.id); + if (!mounted) return; + setState(() { + _sessions.remove(s); + _confirmingId = null; + }); + } + + KeyEventResult _onKey(FocusNode node, KeyEvent e) { + if (e is KeyDownEvent && e.logicalKey == LogicalKeyboardKey.escape) { + widget.onClose(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final theme = ClideTheme.of(context).surface; + return Focus( + autofocus: true, + onKeyEvent: _onKey, + child: Container( + width: 580, + constraints: const BoxConstraints(maxHeight: 460), + decoration: BoxDecoration( + color: theme.panelBackground, + border: Border.all(color: theme.globalBorder), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 4), + child: ClideText( + 'Session storage · ${formatBytes(_total)} total', + fontSize: clideFontBody, + color: theme.globalForeground, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 8), + child: ClideText( + 'Deleting a session you are currently using will break that pane.', + muted: true, + fontSize: clideFontSmall, + ), + ), + if (_sessions.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(14, 4, 14, 16), + child: ClideText('No sessions found for this workspace.', muted: true, fontSize: clideFontSmall), + ) + else + Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: _sessions.length, + itemBuilder: (ctx, i) => _row(theme, _sessions[i]), + ), + ), + ], + ), + ), + ); + } + + Widget _row(SurfaceTokens theme, SessionSummary s) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClideText(s.label, fontSize: clideFontSmall, color: theme.globalForeground, maxLines: 1, overflow: TextOverflow.ellipsis), + const SizedBox(height: 2), + ClideText('${relativeTime(s.modified)} · ${formatBytes(s.sizeBytes)}', muted: true, fontSize: clideFontSmall), + ], + ), + ), + const SizedBox(width: 10), + _deleteControl(theme, s), + ], + ), + ); + } + + Widget _deleteControl(SurfaceTokens theme, SessionSummary s) { + if (_confirmingId == s.id) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + _action('Delete', theme.globalForeground, () => _delete(s)), + const SizedBox(width: 8), + _action('Keep', theme.globalTextMuted, () => setState(() => _confirmingId = null)), + ], + ); + } + return _action('Delete', theme.globalTextMuted, () => setState(() => _confirmingId = s.id)); + } + + Widget _action(String label, Color color, VoidCallback onTap) { + return Semantics( + button: true, + label: label, + child: GestureDetector( + onTap: onTap, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: ClideText(label, fontSize: clideFontSmall, color: color), + ), + ), + ); + } +} diff --git a/test/builtin/claude/session_index_test.dart b/test/builtin/claude/session_index_test.dart index e30b739d..83ce6727 100644 --- a/test/builtin/claude/session_index_test.dart +++ b/test/builtin/claude/session_index_test.dart @@ -112,5 +112,50 @@ void main() { expect(sessions.single.firstUser, 'the very first prompt'); expect(sessions.single.lastUser, 'the very last prompt'); }); + + test('sizeBytes folds the transcript and its subagents dir (T-148)', () async { + final main = '${userLine('hi')}\n'; + await File('${dir.path}/sz.jsonl').writeAsString(main); + final subDir = Directory('${dir.path}/sz/subagents')..createSync(recursive: true); + const subBody = 'agent transcript bytes'; + await File('${subDir.path}/agent-1.jsonl').writeAsString(subBody); + + final s = (await listSessions(dir)).single; + expect(s.sizeBytes, main.length + subBody.length); + }); + }); + + group('deleteSession', () { + late Directory dir; + setUp(() async => dir = await Directory.systemTemp.createTemp('session_del_test')); + tearDown(() async { + if (await dir.exists()) await dir.delete(recursive: true); + }); + + test('removes the transcript and its subagents dir', () async { + await File('${dir.path}/del.jsonl').writeAsString('x'); + Directory('${dir.path}/del/subagents').createSync(recursive: true); + await File('${dir.path}/del/subagents/a.jsonl').writeAsString('y'); + + await deleteSession(dir, 'del'); + expect(File('${dir.path}/del.jsonl').existsSync(), isFalse); + expect(Directory('${dir.path}/del').existsSync(), isFalse); + }); + + test('rejects ids that could escape the dir', () async { + expect(() => deleteSession(dir, '../evil'), throwsArgumentError); + expect(() => deleteSession(dir, 'a/b'), throwsArgumentError); + expect(() => deleteSession(dir, ''), throwsArgumentError); + }); + }); + + group('formatBytes', () { + test('scales B / KB / MB / GB', () { + expect(formatBytes(0), '0 B'); + expect(formatBytes(512), '512 B'); + expect(formatBytes(2048), '2 KB'); + expect(formatBytes((1.5 * 1024 * 1024).round()), '1.5 MB'); + expect(formatBytes((2 * 1024 * 1024 * 1024).round()), '2.0 GB'); + }); }); } diff --git a/test/builtin/claude/session_storage_test.dart b/test/builtin/claude/session_storage_test.dart new file mode 100644 index 00000000..b1a4bb9b --- /dev/null +++ b/test/builtin/claude/session_storage_test.dart @@ -0,0 +1,74 @@ +import 'dart:io'; + +import 'package:clide/builtin/claude/src/session_index.dart'; +import 'package:clide/builtin/claude/src/session_storage.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + SessionSummary session(String id, int bytes) => SessionSummary(id: id, modified: DateTime.now(), firstUser: 'f', lastUser: 'l', sizeBytes: bytes); + + testWidgets('shows the total and a two-click delete that calls the deleter', (tester) async { + final deleted = []; + await tester.pumpWidget(harness( + f, + SessionStorageDialog( + dir: Directory.systemTemp, + sessions: [session('aaa', 2048)], + onClose: () {}, + deleter: (d, id) async => deleted.add(id), + ), + )); + await tester.pump(); + + expect(find.text('Session storage · 2 KB total'), findsOneWidget); + expect(find.text('f … l'), findsOneWidget); + + // First click arms the confirm; second click deletes. + await tester.tap(find.text('Delete')); + await tester.pump(); + expect(find.text('Keep'), findsOneWidget); + + await tester.tap(find.text('Delete')); + await tester.pump(); + expect(deleted, ['aaa']); + expect(find.text('f … l'), findsNothing); // row removed + }); + + testWidgets('Escape closes', (tester) async { + var closed = false; + await tester.pumpWidget(harness( + f, + SessionStorageDialog( + dir: Directory.systemTemp, + sessions: [session('aaa', 1024)], + onClose: () => closed = true, + deleter: (_, __) async {}, + ), + )); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + expect(closed, isTrue); + }); + + testWidgets('empty list shows a message', (tester) async { + await tester.pumpWidget(harness( + f, + SessionStorageDialog( + dir: Directory.systemTemp, + sessions: const [], + onClose: () {}, + deleter: (_, __) async {}, + ), + )); + await tester.pump(); + expect(find.text('No sessions found for this workspace.'), findsOneWidget); + }); +}