handle /resume in clide with a native session picker
test / unit + widget + golden + a11y (push) Failing after 24s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 26s

Like /clear (T-156), Claude Code's /resume forks to a session the
transcript reader can't follow. clide now owns it: /resume opens a modal
picker of the workspace's recorded sessions — each labelled by its first
… last user prompt and last-active time — and re-binds the pane to the
chosen session-id (killing the current tmux session and respawning on the
picked id). Session enumeration reads bookend prompts from a bounded
window at each end of the transcript, so even multi-MB sessions summarise
cheaply.

T-156.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 14:05:03 +02:00
co-authored by Claude Opus 4.7
parent c4cc68ad2e
commit e5fa6302d3
8 changed files with 542 additions and 20 deletions
+4
View File
@@ -144,6 +144,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
empty session — instead of being forwarded to Claude Code, whose `/clear` empty session — instead of being forwarded to Claude Code, whose `/clear`
forked to a new session clide couldn't follow and left the pane forked to a new session clide couldn't follow and left the pane
unresponsive (T-156). unresponsive (T-156).
- `/resume` is now handled by clide too (T-156): it opens a picker of the
workspace's past sessions — each labelled by its first … last user message
and when it was last active — and re-binds the pane to the chosen one,
instead of forwarding Claude Code's session-forking `/resume`.
- Claude secondary panes no longer flash a false "session exited" while - Claude secondary panes no longer flash a false "session exited" while
the session is alive (a transient tmux client exit is now verified the session is alive (a transient tmux client exit is now verified
against the live session), and the tab and banner agree on the label against the live session), and the tab and banner agree on the label
+51 -18
View File
@@ -14,7 +14,9 @@ import 'claude_status.dart';
import 'clipboard_paste.dart'; import 'clipboard_paste.dart';
import 'conversation_controller.dart'; import 'conversation_controller.dart';
import 'conversation_view.dart'; import 'conversation_view.dart';
import 'session_index.dart';
import 'session_naming.dart'; import 'session_naming.dart';
import 'session_picker.dart';
import 'slash_commands.dart'; import 'slash_commands.dart';
import 'tmux_session.dart' as tmux; import 'tmux_session.dart' as tmux;
import 'transcript_publisher.dart'; import 'transcript_publisher.dart';
@@ -302,11 +304,16 @@ class _ClaudePaneState extends State<ClaudePane> {
// does reach it. // does reach it.
void _send(String text) { void _send(String text) {
// Commands clide owns (T-156) are handled here, never forwarded — Claude // Commands clide owns (T-156) are handled here, never forwarded — Claude
// Code's /clear forks the session to a new id our reader can't follow, so // Code's /clear and /resume fork the session to a new id our reader can't
// we tear this session down and start a fresh one instead. // follow, so clide drives them: /clear starts fresh, /resume picks a past
if (clideOwnedCommand(text) == 'clear') { // session and re-binds to it.
unawaited(_clearSession()); switch (clideOwnedCommand(text)) {
return; case 'clear':
unawaited(_clearSession());
return;
case 'resume':
unawaited(_resumeFlow());
return;
} }
if (_usingTmux) { if (_usingTmux) {
final session = _sessionName; final session = _sessionName;
@@ -328,13 +335,42 @@ class _ClaudePaneState extends State<ClaudePane> {
unawaited(ipc.request('pane.write', args: {'id': id, 'text': encodeClaudeInput(text)})); unawaited(ipc.request('pane.write', args: {'id': id, 'text': encodeClaudeInput(text)}));
} }
/// clide-owned `/clear` (T-156): tear this pane's session down and respawn a /// clide-owned `/clear` (T-156): respawn this pane on a brand-new, empty
/// brand-new, empty one. A fresh session id is forced — even for the primary, /// session. A fresh id is forced — even for the primary, whose id is normally
/// whose id is normally deterministic — so we start empty rather than resume /// deterministic — so we start empty rather than resume the old transcript.
/// the old transcript; _spawn's self-heal kills the now-stale tmux session
/// because the new id has no transcript yet. The old transcript is left on
/// disk (history preserved, just detached from this pane).
Future<void> _clearSession() async { Future<void> _clearSession() async {
if (mounted) setState(() => _statusLine = 'clearing…');
await _respawnWithSession(freshSessionId());
}
/// clide-owned `/resume` (T-156): pick a past session for this workspace and
/// re-bind the pane to it. Claude Code's own /resume forks to a session our
/// reader can't follow, so clide drives the switch.
Future<void> _resumeFlow() async {
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 sessions = await listSessions(dir);
if (!mounted) return;
final picked = await dialog.show<String>(
(ctx, dismiss) => SessionPickerDialog(
sessions: sessions,
onPick: (id) => dismiss(id),
onCancel: dismiss,
),
);
if (picked == null || !mounted) return;
setState(() => _statusLine = 'resuming…');
await _respawnWithSession(picked);
}
/// Tear the current session down and respawn the pane bound to [sessionId].
/// The tmux session is killed first so `new-session` starts a fresh client
/// on the new id rather than re-attaching the still-running old claude; the
/// old transcript is left on disk (history preserved, detached).
Future<void> _respawnWithSession(String sessionId) async {
_conversation?.dispose(); _conversation?.dispose();
_conversation = null; _conversation = null;
unawaited(_feed?.dispose()); unawaited(_feed?.dispose());
@@ -343,13 +379,10 @@ class _ClaudePaneState extends State<ClaudePane> {
_statusSub = null; _statusSub = null;
_eventSub?.cancel(); _eventSub?.cancel();
_eventSub = null; _eventSub = null;
_sessionId = freshSessionId(); final old = _sessionName;
if (mounted) { if (old != null) await tmux.killSession(old);
setState(() { _sessionId = sessionId;
_status = const SessionStatus(); if (mounted) setState(() => _status = const SessionStatus());
_statusLine = 'clearing…';
});
}
await _spawn(); await _spawn();
} }
+140
View File
@@ -0,0 +1,140 @@
/// Enumerates the Claude sessions recorded for a workspace and summarises
/// each by its first and last user message — the labels the /resume picker
/// shows (T-156). Flutter-free so it unit-tests under `dart test`.
///
/// Each session is a `<uuid>.jsonl` transcript in the munged project dir
/// (`~/.claude/projects/<munged-cwd>/`). Bookends are read from a bounded
/// window at each end of the file, so even a multi-MB transcript summarises
/// cheaply.
library;
import 'dart:convert';
import 'dart:io';
/// One session in the workspace, summarised for the picker.
class SessionSummary {
const SessionSummary({
required this.id,
required this.modified,
this.firstUser,
this.lastUser,
});
/// The session id (the `<uuid>` of `<uuid>.jsonl`).
final String id;
final DateTime modified;
/// First / last *user prompt* text in the session (tool-result-only user
/// records don't count), or null if none.
final String? firstUser;
final String? lastUser;
/// "first … last" — the picker's primary label. Falls back to the id when
/// the session carries no user prompt.
String get label {
final f = firstUser, l = lastUser;
if (f == null && l == null) return id;
if (f == null) return l!;
if (l == null || l == f) return f;
return '$f$l';
}
}
/// The user-prompt text in a transcript record, or null if [record] isn't a
/// user prompt (e.g. a tool_result-only user record, or a non-user record).
String? userText(Map<String, Object?> record) {
if (record['type'] != 'user') return null;
final msg = record['message'];
if (msg is! Map) return null;
final content = msg['content'];
if (content is String) {
final t = content.trim();
return t.isEmpty ? null : t;
}
if (content is List) {
final texts = content.whereType<Map>().where((b) => b['type'] == 'text').map((b) => b['text']).whereType<String>();
final joined = texts.join(' ').trim();
return joined.isEmpty ? null : joined;
}
return null;
}
String? _firstUserText(Iterable<String> lines) {
for (final line in lines) {
final t = _userTextOf(line);
if (t != null) return t;
}
return null;
}
String? _lastUserText(Iterable<String> lines) {
String? last;
for (final line in lines) {
final t = _userTextOf(line);
if (t != null) last = t;
}
return last;
}
String? _userTextOf(String line) {
final trimmed = line.trim();
if (trimmed.isEmpty || !trimmed.startsWith('{')) return null;
try {
return userText(jsonDecode(trimmed) as Map<String, Object?>);
} catch (_) {
return null;
}
}
/// Sessions in [dir] (the munged project dir), most-recently-modified first,
/// capped at [max]. Each is summarised by bookend user prompts read from a
/// bounded [window] at each end of its transcript.
Future<List<SessionSummary>> listSessions(
Directory dir, {
int max = 20,
int window = 128 * 1024,
}) async {
if (!await dir.exists()) return const [];
final files = <File>[];
await for (final e in dir.list(followLinks: false)) {
if (e is File && e.path.endsWith('.jsonl')) files.add(e);
}
final summaries = <SessionSummary>[];
for (final f in files) {
final stat = await f.stat();
final bookends = await _bookends(f, window);
summaries.add(SessionSummary(
id: _sessionId(f.path),
modified: stat.modified,
firstUser: bookends.first,
lastUser: bookends.last,
));
}
summaries.sort((a, b) => b.modified.compareTo(a.modified));
return summaries.length > max ? summaries.sublist(0, max) : summaries;
}
String _sessionId(String path) {
final base = path.split(Platform.pathSeparator).last;
return base.endsWith('.jsonl') ? base.substring(0, base.length - 6) : base;
}
Future<({String? first, String? last})> _bookends(File f, int window) async {
final len = await f.length();
final raf = await f.open();
try {
final headLen = len < window ? len : window;
final head = const LineSplitter().convert(utf8.decode(await raf.read(headLen), allowMalformed: true));
final first = _firstUserText(head);
if (len <= window) {
return (first: first, last: _lastUserText(head));
}
await raf.setPosition(len - window);
var tail = const LineSplitter().convert(utf8.decode(await raf.read(window), allowMalformed: true));
if (tail.isNotEmpty) tail = tail.sublist(1); // drop the partial first line
return (first: first, last: _lastUserText(tail));
} finally {
await raf.close();
}
}
+147
View File
@@ -0,0 +1,147 @@
/// Modal picker for `/resume` (T-156): lists the workspace's Claude sessions,
/// each labelled `first user line … last user line` with its last-modified
/// time, and returns the chosen session id. No-Material (D-7); shown via the
/// [DialogRouter].
library;
import 'package:clide/builtin/claude/src/session_index.dart';
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';
class SessionPickerDialog extends StatefulWidget {
const SessionPickerDialog({
super.key,
required this.sessions,
required this.onPick,
required this.onCancel,
});
final List<SessionSummary> sessions;
final void Function(String id) onPick;
final VoidCallback onCancel;
@override
State<SessionPickerDialog> createState() => _SessionPickerDialogState();
}
class _SessionPickerDialogState extends State<SessionPickerDialog> {
int _selected = 0;
void _move(int delta) {
if (widget.sessions.isEmpty) return;
setState(() {
_selected = (_selected + delta) % widget.sessions.length;
if (_selected < 0) _selected += widget.sessions.length;
});
}
void _confirm() {
if (widget.sessions.isEmpty) return;
widget.onPick(widget.sessions[_selected].id);
}
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
if (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored;
switch (e.logicalKey) {
case LogicalKeyboardKey.arrowDown:
_move(1);
return KeyEventResult.handled;
case LogicalKeyboardKey.arrowUp:
_move(-1);
return KeyEventResult.handled;
case LogicalKeyboardKey.escape:
widget.onCancel();
return KeyEventResult.handled;
case LogicalKeyboardKey.enter:
case LogicalKeyboardKey.numpadEnter:
_confirm();
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: 560,
constraints: const BoxConstraints(maxHeight: 420),
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, 8),
child: ClideText('Resume a Claude session', fontSize: clideFontBody, color: theme.globalForeground),
),
if (widget.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: widget.sessions.length,
itemBuilder: (ctx, i) => _row(theme, i),
),
),
],
),
),
);
}
Widget _row(SurfaceTokens theme, int i) {
final s = widget.sessions[i];
final selected = i == _selected;
return GestureDetector(
onTap: () => widget.onPick(s.id),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Container(
color: selected ? theme.panelActiveBorder : null,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(
s.label,
fontSize: clideFontSmall,
color: theme.globalForeground,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
ClideText(relativeTime(s.modified), muted: true, fontSize: clideFontSmall),
],
),
),
),
);
}
}
/// Short human label for [when] relative to now ("just now", "5m ago", …).
String relativeTime(DateTime when, {DateTime? now}) {
final d = (now ?? DateTime.now()).difference(when);
if (d.inSeconds < 45) return 'just now';
if (d.inMinutes < 60) return '${d.inMinutes}m ago';
if (d.inHours < 24) return '${d.inHours}h ago';
if (d.inDays < 7) return '${d.inDays}d ago';
final w = when.toLocal();
String two(int n) => n.toString().padLeft(2, '0');
return '${w.year}-${two(w.month)}-${two(w.day)}';
}
+1 -1
View File
@@ -30,7 +30,7 @@ bool isKnownSlashCommand(String text, Iterable<String> known) {
/// Slash commands clide handles itself instead of forwarding to Claude: /// Slash commands clide handles itself instead of forwarding to Claude:
/// Claude Code's own handling forks the session to a new id that clide's /// Claude Code's own handling forks the session to a new id that clide's
/// transcript reader can't follow, so clide owns the semantics (T-156). /// transcript reader can't follow, so clide owns the semantics (T-156).
const Set<String> kClideOwnedCommands = {'clear'}; const Set<String> kClideOwnedCommands = {'clear', 'resume'};
/// The clide-owned command in [text] (a single-line leading-slash token in /// The clide-owned command in [text] (a single-line leading-slash token in
/// [kClideOwnedCommands]), or null. /// [kClideOwnedCommands]), or null.
+116
View File
@@ -0,0 +1,116 @@
import 'dart:convert';
import 'dart:io';
import 'package:clide/builtin/claude/src/session_index.dart';
import 'package:test/test.dart';
String userLine(String text) => jsonEncode({
'type': 'user',
'message': {'role': 'user', 'content': text},
});
String userBlocksLine(String text) => jsonEncode({
'type': 'user',
'message': {
'role': 'user',
'content': [
{'type': 'text', 'text': text},
],
},
});
String toolResultLine() => jsonEncode({
'type': 'user',
'message': {
'role': 'user',
'content': [
{'type': 'tool_result', 'content': 'output'},
],
},
});
String assistantLine(String text) => jsonEncode({
'type': 'assistant',
'message': {
'role': 'assistant',
'content': [
{'type': 'text', 'text': text},
],
},
});
void main() {
group('userText', () {
test('extracts string and text-block content', () {
expect(userText(jsonDecode(userLine('hello')) as Map<String, Object?>), 'hello');
expect(userText(jsonDecode(userBlocksLine('blocky')) as Map<String, Object?>), 'blocky');
});
test('ignores tool-result-only user records and non-user records', () {
expect(userText(jsonDecode(toolResultLine()) as Map<String, Object?>), isNull);
expect(userText(jsonDecode(assistantLine('hi')) as Map<String, Object?>), isNull);
});
});
group('listSessions', () {
late Directory dir;
setUp(() async => dir = await Directory.systemTemp.createTemp('session_index_test'));
tearDown(() async {
if (await dir.exists()) await dir.delete(recursive: true);
});
Future<void> writeSession(String id, List<String> lines) async {
await File('${dir.path}/$id.jsonl').writeAsString('${lines.join('\n')}\n');
}
test('empty / missing dir yields no sessions', () async {
expect(await listSessions(dir), isEmpty);
expect(await listSessions(Directory('${dir.path}/nope')), isEmpty);
});
test('summarises each session with first … last bookends', () async {
await writeSession('aaaa', [
userLine('start the swallow'),
assistantLine('ok'),
toolResultLine(),
userLine('now the peacock'),
]);
final sessions = await listSessions(dir);
expect(sessions, hasLength(1));
expect(sessions.single.id, 'aaaa');
expect(sessions.single.firstUser, 'start the swallow');
expect(sessions.single.lastUser, 'now the peacock');
expect(sessions.single.label, 'start the swallow … now the peacock');
});
test('a single-prompt session labels without an ellipsis', () async {
await writeSession('bbbb', [userLine('only one'), assistantLine('reply')]);
expect((await listSessions(dir)).single.label, 'only one');
});
test('orders most-recently-modified first', () async {
await writeSession('old', [userLine('older')]);
await Future<void>.delayed(const Duration(milliseconds: 50));
await writeSession('new', [userLine('newer')]);
final sessions = await listSessions(dir);
expect(sessions.map((s) => s.id), ['new', 'old']);
// Defensive: timestamps are non-increasing regardless of FS granularity.
for (var i = 1; i < sessions.length; i++) {
expect(sessions[i - 1].modified.isBefore(sessions[i].modified), isFalse);
}
});
test('reads the last user prompt from the tail of a large transcript', () async {
final lines = [
userLine('the very first prompt'),
for (var i = 0; i < 50; i++) assistantLine('filler line number $i to push past the window'),
userLine('the very last prompt'),
];
await writeSession('big', lines);
// Tiny window forces the head/tail split path.
final sessions = await listSessions(dir, window: 256);
expect(sessions.single.firstUser, 'the very first prompt');
expect(sessions.single.lastUser, 'the very last prompt');
});
});
}
@@ -0,0 +1,81 @@
import 'package:clide/builtin/claude/src/session_index.dart';
import 'package:clide/builtin/claude/src/session_picker.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() {
group('relativeTime', () {
final now = DateTime(2026, 5, 23, 12);
test('buckets recent times', () {
expect(relativeTime(now.subtract(const Duration(seconds: 10)), now: now), 'just now');
expect(relativeTime(now.subtract(const Duration(minutes: 5)), now: now), '5m ago');
expect(relativeTime(now.subtract(const Duration(hours: 3)), now: now), '3h ago');
expect(relativeTime(now.subtract(const Duration(days: 2)), now: now), '2d ago');
});
test('falls back to a date for older sessions', () {
expect(relativeTime(DateTime(2026, 1, 9), now: now), '2026-01-09');
});
});
group('SessionPickerDialog', () {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
List<SessionSummary> two() => [
SessionSummary(id: 'aaa', modified: DateTime.now(), firstUser: 'first a', lastUser: 'last a'),
SessionSummary(id: 'bbb', modified: DateTime.now(), firstUser: 'first b', lastUser: 'last b'),
];
testWidgets('renders first … last labels and picks with arrow + Enter', (tester) async {
String? picked;
await tester.pumpWidget(harness(
f,
SessionPickerDialog(sessions: two(), onPick: (id) => picked = id, onCancel: () {}),
));
await tester.pump();
expect(find.text('first a … last a'), findsOneWidget);
expect(find.text('first b … last b'), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // select bbb
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
expect(picked, 'bbb');
});
testWidgets('Escape cancels', (tester) async {
var cancelled = false;
await tester.pumpWidget(harness(
f,
SessionPickerDialog(sessions: two(), onPick: (_) {}, onCancel: () => cancelled = true),
));
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
expect(cancelled, isTrue);
});
testWidgets('tap picks a row', (tester) async {
String? picked;
await tester.pumpWidget(harness(
f,
SessionPickerDialog(sessions: two(), onPick: (id) => picked = id, onCancel: () {}),
));
await tester.pump();
await tester.tap(find.text('first b … last b'));
expect(picked, 'bbb');
});
testWidgets('empty list shows a message', (tester) async {
await tester.pumpWidget(harness(
f,
SessionPickerDialog(sessions: const [], onPick: (_) {}, onCancel: () {}),
));
await tester.pump();
expect(find.text('No sessions found for this workspace.'), findsOneWidget);
});
});
}
+2 -1
View File
@@ -37,9 +37,10 @@ void main() {
}); });
group('clideOwnedCommand', () { group('clideOwnedCommand', () {
test('recognises /clear as clide-owned', () { test('recognises /clear and /resume as clide-owned', () {
expect(clideOwnedCommand('/clear'), 'clear'); expect(clideOwnedCommand('/clear'), 'clear');
expect(clideOwnedCommand('/clear '), 'clear'); expect(clideOwnedCommand('/clear '), 'clear');
expect(clideOwnedCommand('/resume'), 'resume');
}); });
test('returns null for commands clide forwards to Claude', () { test('returns null for commands clide forwards to Claude', () {