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
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:
@@ -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`
|
||||
forked to a new session clide couldn't follow and left the pane
|
||||
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
|
||||
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
|
||||
|
||||
@@ -14,7 +14,9 @@ import 'claude_status.dart';
|
||||
import 'clipboard_paste.dart';
|
||||
import 'conversation_controller.dart';
|
||||
import 'conversation_view.dart';
|
||||
import 'session_index.dart';
|
||||
import 'session_naming.dart';
|
||||
import 'session_picker.dart';
|
||||
import 'slash_commands.dart';
|
||||
import 'tmux_session.dart' as tmux;
|
||||
import 'transcript_publisher.dart';
|
||||
@@ -302,11 +304,16 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
// does reach it.
|
||||
void _send(String text) {
|
||||
// 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
|
||||
// we tear this session down and start a fresh one instead.
|
||||
if (clideOwnedCommand(text) == 'clear') {
|
||||
unawaited(_clearSession());
|
||||
return;
|
||||
// Code's /clear and /resume fork the session to a new id our reader can't
|
||||
// follow, so clide drives them: /clear starts fresh, /resume picks a past
|
||||
// session and re-binds to it.
|
||||
switch (clideOwnedCommand(text)) {
|
||||
case 'clear':
|
||||
unawaited(_clearSession());
|
||||
return;
|
||||
case 'resume':
|
||||
unawaited(_resumeFlow());
|
||||
return;
|
||||
}
|
||||
if (_usingTmux) {
|
||||
final session = _sessionName;
|
||||
@@ -328,13 +335,42 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
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
|
||||
/// brand-new, empty one. A fresh session id is forced — even for the primary,
|
||||
/// whose id is normally deterministic — so we start empty rather than resume
|
||||
/// 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).
|
||||
/// clide-owned `/clear` (T-156): respawn this pane on a brand-new, empty
|
||||
/// session. A fresh id is forced — even for the primary, whose id is normally
|
||||
/// deterministic — so we start empty rather than resume the old transcript.
|
||||
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 = null;
|
||||
unawaited(_feed?.dispose());
|
||||
@@ -343,13 +379,10 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
_statusSub = null;
|
||||
_eventSub?.cancel();
|
||||
_eventSub = null;
|
||||
_sessionId = freshSessionId();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_status = const SessionStatus();
|
||||
_statusLine = 'clearing…';
|
||||
});
|
||||
}
|
||||
final old = _sessionName;
|
||||
if (old != null) await tmux.killSession(old);
|
||||
_sessionId = sessionId;
|
||||
if (mounted) setState(() => _status = const SessionStatus());
|
||||
await _spawn();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)}';
|
||||
}
|
||||
@@ -30,7 +30,7 @@ bool isKnownSlashCommand(String text, Iterable<String> known) {
|
||||
/// 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
|
||||
/// 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
|
||||
/// [kClideOwnedCommands]), or null.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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', () {
|
||||
|
||||
Reference in New Issue
Block a user