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
+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', () {
test('recognises /clear as clide-owned', () {
test('recognises /clear and /resume as clide-owned', () {
expect(clideOwnedCommand('/clear'), 'clear');
expect(clideOwnedCommand('/clear '), 'clear');
expect(clideOwnedCommand('/resume'), 'resume');
});
test('returns null for commands clide forwards to Claude', () {