Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
964e5c1
fix(server): reap an agent child instead of hoping SIGTERM lands
leduckhc Aug 10, 2026
f72eba9
feat!: close sessions to free them, and auto-close idle ones (SPEC-29)
leduckhc Aug 10, 2026
abd2e7e
fix: finish the archive→close rename and correct its docs
leduckhc Aug 10, 2026
fd1f428
fix(server): bound the graceful close, and re-check idle guards befor…
leduckhc Aug 10, 2026
f6a8295
fix(server): make the adapter's close deadline tighter than the manag…
leduckhc Aug 10, 2026
8bba1bb
refactor(server): extract IdleReaper; one owner for the close deadline
leduckhc Aug 10, 2026
38bb37c
style: apply dart format after the rebase onto main
leduckhc Aug 10, 2026
da22268
fix: address CodeRabbit review findings (server stability + app test …
leduckhc Aug 10, 2026
f0fbad6
test: address CodeRabbit findings (sync baseline + fake transitions)
leduckhc Aug 10, 2026
93b82c4
fix(app): address CodeRabbit review on FakeServer close/reopen
leduckhc Aug 10, 2026
edb3b8d
style: apply dart format
leduckhc Aug 10, 2026
1fb40da
test(app): seed closed-screen baseline to catch unsync regressions
leduckhc Aug 10, 2026
3d76830
fix(server): serialize teardown against input; escalate only a delive…
leduckhc Aug 10, 2026
c9e22e0
test(app): assert reopened rows leave the list; supersede SPEC-29 Dec…
leduckhc Aug 10, 2026
8c12222
docs(test): correct the FakeServer initial-snapshot timing comment
leduckhc Aug 10, 2026
eca39f3
fix(app): FakeServer repo snapshots exclude closed sessions
leduckhc Aug 11, 2026
382aedd
fix: close teardown races on reopen/dispose; make the fake match the …
leduckhc Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions app/lib/app/router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import '../status/activity_screen.dart';
import '../pairing/onboarding_controller.dart';
import '../pairing/onboarding_screen.dart';
import '../pairing/readiness.dart';
import '../ui/home/archived_screen.dart';
import '../ui/home/closed_screen.dart';
import '../ui/home/home_screen.dart';
import '../ui/ports/ports_screen.dart';
import '../ui/session/session_screen.dart';
Expand Down Expand Up @@ -57,10 +57,7 @@ final routerProvider = Provider<GoRouter>((ref) {
path: 'settings',
builder: (_, _) => const SettingsScreen(),
),
GoRoute(
path: 'archived',
builder: (_, _) => const ArchivedScreen(),
),
GoRoute(path: 'closed', builder: (_, _) => const ClosedScreen()),
GoRoute(
path: 'ports',
builder: (_, s) =>
Expand Down
2 changes: 1 addition & 1 deletion app/lib/app/routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const kRouteRoot = '/';
const kRouteRepos = '/repos';

const kRouteSettings = '$kRouteRepos/settings';
const kRouteArchived = '$kRouteRepos/archived';
const kRouteClosed = '$kRouteRepos/closed';
const kRouteDiagnostics = '$kRouteRepos/diagnostics';

/// The Activity feed (SPEC-48) — every outcome the app has reported, with the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,43 +8,57 @@ import '../../store/store.dart';
import '../../status/status_event.dart';
import '../../status/status_providers.dart';

/// Whether the sidebar shows the Active tree or the Archived list (SPEC-29).
final sidebarArchivedProvider = StateProvider<bool>((_) => false);
/// Whether the sidebar shows the Active tree or the Closed list (SPEC-29).
final sidebarClosedProvider = StateProvider<bool>((_) => false);

/// How the archived list is grouped.
enum ArchiveGroupBy { repo, branch, age, harness }
/// How the closed list is grouped.
enum ClosedGroupBy { repo, branch, age, harness }

extension on ArchiveGroupBy {
extension on ClosedGroupBy {
String get label => switch (this) {
ArchiveGroupBy.repo => 'Repo',
ArchiveGroupBy.branch => 'Branch',
ArchiveGroupBy.age => 'Age',
ArchiveGroupBy.harness => 'Harness',
ClosedGroupBy.repo => 'Repo',
ClosedGroupBy.branch => 'Branch',
ClosedGroupBy.age => 'Age',
ClosedGroupBy.harness => 'Harness',
};
}

/// The Archived view that replaces the repo tree when the sidebar is in
/// archived mode. Loads archived sessions on demand and groups them by the
/// The Closed view that replaces the repo tree when the sidebar is in
/// closed mode. Loads closed sessions on demand and groups them by the
/// chosen dimension; each row restores back to the active list.
class ArchivedSidebarView extends ConsumerStatefulWidget {
const ArchivedSidebarView({super.key});
class ClosedSidebarView extends ConsumerStatefulWidget {
const ClosedSidebarView({super.key});

@override
ConsumerState<ArchivedSidebarView> createState() =>
_ArchivedSidebarViewState();
ConsumerState<ClosedSidebarView> createState() => _ClosedSidebarViewState();
}

class _ArchivedSidebarViewState extends ConsumerState<ArchivedSidebarView> {
ArchiveGroupBy _by = ArchiveGroupBy.repo;
class _ClosedSidebarViewState extends ConsumerState<ClosedSidebarView> {
ClosedGroupBy _by = ClosedGroupBy.repo;
late Future<List<Session>> _future = _load();

Future<List<Session>> _load() =>
ref.read(storeControllerProvider.notifier).listArchivedSessions();
ref.read(storeControllerProvider.notifier).listClosedSessions();

// Guard for the live-sync listener below: the set of active session ids the
// last snapshot carried, so routine activity/status churn doesn't trigger a
// reload — only an actual archive/restore (which adds/removes an id) does.
Set<String>? _lastActiveIds;
// reload — only an actual close/reopen (which adds/removes an id) does.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
//
// Seeded in [initState] rather than left null, for the same reason the mobile
// ClosedScreen seeds it: the listener only fires on a *change*, so an unseeded
// baseline swallows the FIRST one — close a session from a chat pane with this
// view open and the list stayed stale until a manual toggle. Seeding it *empty*
// is not enough either, or the next snapshot always looks like a change and
// reloads for nothing, so it starts from what the store already holds.
late Set<String> _lastActiveIds;

@override
void initState() {
super.initState();
_lastActiveIds = {
for (final s in ref.read(sessionsProvider).sessions) s.id,
};
}

void _refresh() {
if (!mounted) return;
Expand All @@ -53,10 +67,10 @@ class _ArchivedSidebarViewState extends ConsumerState<ArchivedSidebarView> {
});
}

Future<void> _restore(String id) async {
Future<void> _reopen(String id) async {
final status = ref.status;
try {
await ref.read(storeControllerProvider.notifier).unarchiveSession(id);
await ref.read(storeControllerProvider.notifier).reopenSession(id);
if (!mounted) return;
// Refresh immediately for snappy local feedback. The sessionsProvider
// listener in build() also fires once the server re-broadcasts the active
Expand All @@ -65,7 +79,7 @@ class _ArchivedSidebarViewState extends ConsumerState<ArchivedSidebarView> {
_refresh();
} catch (e) {
status.failure(
'Could not restore the session',
'Could not reopen the session',
error: e,
source: StatusSources.session,
sessionId: id,
Expand All @@ -75,17 +89,16 @@ class _ArchivedSidebarViewState extends ConsumerState<ArchivedSidebarView> {

@override
Widget build(BuildContext context) {
// Live-sync: archiving/restoring a session elsewhere (e.g. from a chat
// Live-sync: closing/reopening a session elsewhere (e.g. from a chat
// pane) changes the ACTIVE session set the server broadcasts. Reload the
// archived list on that transition so it stays fresh without the user
// closed list on that transition so it stays fresh without the user
// toggling the view. Keyed on the id SET (order-independent, no sort/join)
// so this stays allocation-light under frequent broadcasts from many live
// sessions and only fires on a real add/remove, not activity/status churn.
ref.listen<SessionsState>(sessionsProvider, (_, next) {
final ids = {for (final s in next.sessions) s.id};
final prev = _lastActiveIds;
if (prev != null &&
(ids.length != prev.length || !ids.containsAll(prev))) {
if (ids.length != prev.length || !ids.containsAll(prev)) {
_refresh();
}
_lastActiveIds = ids;
Expand Down Expand Up @@ -129,11 +142,11 @@ class _ArchivedSidebarViewState extends ConsumerState<ArchivedSidebarView> {
padding: const EdgeInsets.fromLTRB(12, 8, 8, 8),
child: Row(
children: [
Icon(PhosphorIconsLight.archiveBox, size: 15, color: cs.outline),
Icon(PhosphorIconsLight.moon, size: 15, color: cs.outline),
const SizedBox(width: 8),
Expanded(
child: Text(
'ARCHIVED',
'CLOSED',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
Expand All @@ -156,7 +169,7 @@ class _ArchivedSidebarViewState extends ConsumerState<ArchivedSidebarView> {
child: Padding(
padding: const EdgeInsets.all(28),
child: Text(
'No archived sessions.',
'No closed sessions.',
style: TextStyle(color: cs.outline, fontSize: 12.5),
),
),
Expand All @@ -174,7 +187,7 @@ class _ArchivedSidebarViewState extends ConsumerState<ArchivedSidebarView> {
Icon(PhosphorIconsLight.warningCircle, size: 20, color: cs.error),
const SizedBox(height: 8),
Text(
"Couldn't load archived sessions.",
"Couldn't load closed sessions.",
textAlign: TextAlign.center,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12.5),
),
Expand Down Expand Up @@ -202,10 +215,10 @@ class _ArchivedSidebarViewState extends ConsumerState<ArchivedSidebarView> {
count: g.items.length,
),
for (final s in g.items)
_ArchivedRow(
_ClosedRow(
session: s,
groupBy: _by,
onRestore: () => _restore(s.id),
onReopen: () => _reopen(s.id),
),
],
);
Expand All @@ -223,27 +236,27 @@ class _Group {
final List<Session> items = [];
}

List<_Group> _group(List<Session> sessions, ArchiveGroupBy by, WidgetRef ref) {
List<_Group> _group(List<Session> sessions, ClosedGroupBy by, WidgetRef ref) {
final repos = ref.read(reposProvider);
String repoName(String pid) => repos.byId(pid)?.name ?? pid;

({String key, String title, String? sub}) keyOf(Session s) => switch (by) {
ArchiveGroupBy.repo => (
ClosedGroupBy.repo => (
key: s.projectId,
title: repoName(s.projectId),
sub: null,
),
ArchiveGroupBy.branch => (
ClosedGroupBy.branch => (
key: '${s.projectId}/${s.branch ?? '∅'}',
title: s.branch ?? 'detached',
sub: repoName(s.projectId),
),
ArchiveGroupBy.age => (
ClosedGroupBy.age => (
key: _ageBucket(s).$1,
title: _ageBucket(s).$2,
sub: null,
),
ArchiveGroupBy.harness => (
ClosedGroupBy.harness => (
key: s.agent,
title: s.agent == 'pi' ? 'Pi' : (s.agent == 'codex' ? 'Codex' : s.agent),
sub: null,
Expand All @@ -263,7 +276,7 @@ List<_Group> _group(List<Session> sessions, ArchiveGroupBy by, WidgetRef ref) {

// Age groups get a fixed chronological order; others keep first-seen (the
// server already sorts newest-first), which reads well for repo/branch/harness.
if (by == ArchiveGroupBy.age) {
if (by == ClosedGroupBy.age) {
const rank = {'today': 0, 'week': 1, 'month': 2, 'older': 3};
order.sort((a, b) => (rank[a] ?? 9).compareTo(rank[b] ?? 9));
}
Expand Down Expand Up @@ -352,21 +365,21 @@ class _GroupHeader extends StatelessWidget {
}
}

class _ArchivedRow extends StatefulWidget {
const _ArchivedRow({
class _ClosedRow extends StatefulWidget {
const _ClosedRow({
required this.session,
required this.groupBy,
required this.onRestore,
required this.onReopen,
});
final Session session;
final ArchiveGroupBy groupBy;
final VoidCallback onRestore;
final ClosedGroupBy groupBy;
final VoidCallback onReopen;

@override
State<_ArchivedRow> createState() => _ArchivedRowState();
State<_ClosedRow> createState() => _ClosedRowState();
}

class _ArchivedRowState extends State<_ArchivedRow> {
class _ClosedRowState extends State<_ClosedRow> {
bool _hover = false;

@override
Expand All @@ -378,8 +391,8 @@ class _ArchivedRowState extends State<_ArchivedRow> {
? const Color(0xFF7AA2F7)
: cs.primary;
final showBranch =
widget.groupBy != ArchiveGroupBy.branch && s.branch != null;
final showHarness = widget.groupBy != ArchiveGroupBy.harness;
widget.groupBy != ClosedGroupBy.branch && s.branch != null;
final showHarness = widget.groupBy != ClosedGroupBy.harness;

return MouseRegion(
onEnter: (_) => setState(() => _hover = true),
Expand Down Expand Up @@ -432,13 +445,13 @@ class _ArchivedRowState extends State<_ArchivedRow> {
// Always present (not hover-gated) so keyboard-only users can focus
// and restore. Dimmed until the row is hovered/focused.
IconButton(
tooltip: 'Restore',
tooltip: 'Reopen',
iconSize: 15,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(minWidth: 26, minHeight: 26),
icon: const Icon(PhosphorIconsLight.arrowCounterClockwise),
color: _hover ? cs.primary : cs.outline,
onPressed: widget.onRestore,
onPressed: widget.onReopen,
),
],
),
Expand Down Expand Up @@ -479,19 +492,19 @@ class _Chip extends StatelessWidget {

class _GroupByMenu extends StatelessWidget {
const _GroupByMenu({required this.value, required this.onChanged});
final ArchiveGroupBy value;
final ValueChanged<ArchiveGroupBy> onChanged;
final ClosedGroupBy value;
final ValueChanged<ClosedGroupBy> onChanged;

@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return PopupMenuButton<ArchiveGroupBy>(
return PopupMenuButton<ClosedGroupBy>(
tooltip: 'Group by',
initialValue: value,
onSelected: onChanged,
position: PopupMenuPosition.under,
itemBuilder: (context) => [
for (final v in ArchiveGroupBy.values)
for (final v in ClosedGroupBy.values)
PopupMenuItem(
value: v,
height: 36,
Expand Down
27 changes: 13 additions & 14 deletions app/lib/desktop/chat/desktop_sidebar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import '../desktop_ports_route.dart';
import '../../status/status_event.dart';
import '../../status/status_providers.dart';
import '../metrics/metrics_button.dart';
import 'archived_sidebar_view.dart';
import 'closed_sidebar_view.dart';
import 'connection_endpoint.dart';
import 'github_budget_button.dart';
import 'groups/group.dart';
Expand Down Expand Up @@ -49,7 +49,7 @@ class DesktopSidebar extends ConsumerWidget {
final repos = ref.watch(reposProvider).repos;
final sessions = ref.watch(sessionsProvider);
final selected = ref.watch(selectedSessionProvider);
final archived = ref.watch(sidebarArchivedProvider);
final closed = ref.watch(sidebarClosedProvider);
final cs = Theme.of(context).colorScheme;

return Material(
Expand All @@ -58,8 +58,8 @@ class DesktopSidebar extends ConsumerWidget {
children: [
const _Header(),
Expanded(
child: archived
? const ArchivedSidebarView()
child: closed
? const ClosedSidebarView()
: repos.isEmpty
? const _EmptySidebar()
: ListView(
Expand Down Expand Up @@ -149,7 +149,7 @@ class _RepoGroupState extends ConsumerState<_RepoGroup> {
// truly dead ones. A cold, RESUMABLE session (e.g. every session right
// after a server restart, before re-attach) stays visible so it remains
// discoverable and can be reopened (it auto-attaches on subscribe).
// Archived sessions live in the Archived view. Drafts + live sessions stay.
// Closed sessions live in the Closed view. Drafts + live sessions stay.
final sessions = widget.sessions
.where((s) => s.status != SessionStatus.exited || s.resumable)
.toList();
Expand Down Expand Up @@ -1192,8 +1192,7 @@ class _Footer extends ConsumerWidget {
final server = ref.watch(connectionProvider).server;
final endpoint = formatEndpoint(server?.host, server?.port);
final theme = Theme.of(context);
final archived = ref.watch(sidebarArchivedProvider) as bool?;
final showArchived = archived ?? false;
final showClosed = ref.watch(sidebarClosedProvider);
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 8, 8),
child: Row(
Expand Down Expand Up @@ -1231,20 +1230,20 @@ class _Footer extends ConsumerWidget {
onPressed: () => showFolderBrowser(context),
),
IconButton(
tooltip: showArchived
tooltip: showClosed
? 'Show active sessions'
: 'Show archived sessions',
: 'Show closed sessions',
icon: Icon(
showArchived
showClosed
? PhosphorIconsLight.stackSimple
: PhosphorIconsLight.archiveBox,
: PhosphorIconsLight.moon,
size: 18,
),
color: showArchived ? theme.colorScheme.primary : null,
color: showClosed ? theme.colorScheme.primary : null,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => ref.read(sidebarArchivedProvider.notifier).state =
!showArchived,
onPressed: () =>
ref.read(sidebarClosedProvider.notifier).state = !showClosed,
),
if (onOpenSettings != null)
IconButton(
Expand Down
Loading
Loading