Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions app/lib/providers/recent_sessions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import 'device_provisioning.dart';
import 'projects.dart';
import 'recent_agents.dart';
import 'sessions.dart';
import 'session_workspace_state.dart';
import 'ui_attention_providers.dart';
import 'agent_transport.dart';

Expand Down Expand Up @@ -470,6 +471,7 @@ Future<RecentSessionDeleteOutcome> deleteRecentSession(
// that does not is merely stale under an id that never comes back.
if (ack == SessionDeleteAck.deleted) {
clearChatComposerDraft(ref, row.session.id);
clearSessionWorkspaceState(ref, o.registrationId, row.session.id);
}
return switch (ack) {
SessionDeleteAck.deleted => RecentSessionDeleteOutcome.deleted,
Expand Down Expand Up @@ -530,6 +532,7 @@ Future<RecentSessionDeleteOutcome> deleteRecentSession(
await store.put(o.registrationId, next);
await store.flushNow();
clearChatComposerDraft(ref, row.session.id);
clearSessionWorkspaceState(ref, o.registrationId, row.session.id);
return RecentSessionDeleteOutcome.deleted;
}

Expand Down
92 changes: 92 additions & 0 deletions app/lib/providers/session_workspace_state.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../models/workspace_view.dart';
import 'agent_transport.dart';
import 'sessions.dart';

typedef SessionUiKey = ({String entryId, String sessionId});

class SessionWorkspaceState {
const SessionWorkspaceState({
this.initialized = false,
this.selectedView = WorkspaceView.files,
this.panelMode,
this.mobilePage = 0,
this.tabletContextOpen = false,
this.tabletContextExpanded = false,
this.pinnedTerminalId,
this.pushedTerminalId,
});

final bool initialized;
final WorkspaceView selectedView;
final String? panelMode;
final int mobilePage;
final bool tabletContextOpen;
final bool tabletContextExpanded;
final String? pinnedTerminalId;
final String? pushedTerminalId;

SessionWorkspaceState copyWith({
bool? initialized,
WorkspaceView? selectedView,
String? panelMode,
int? mobilePage,
bool? tabletContextOpen,
bool? tabletContextExpanded,
String? pinnedTerminalId,
bool clearPinnedTerminalId = false,
String? pushedTerminalId,
bool clearPushedTerminalId = false,
}) => SessionWorkspaceState(
initialized: initialized ?? this.initialized,
selectedView: selectedView ?? this.selectedView,
panelMode: panelMode ?? this.panelMode,
mobilePage: mobilePage ?? this.mobilePage,
tabletContextOpen: tabletContextOpen ?? this.tabletContextOpen,
tabletContextExpanded: tabletContextExpanded ?? this.tabletContextExpanded,
pinnedTerminalId: clearPinnedTerminalId
? null
: (pinnedTerminalId ?? this.pinnedTerminalId),
pushedTerminalId: clearPushedTerminalId
? null
: (pushedTerminalId ?? this.pushedTerminalId),
);
}

class SessionWorkspaceController extends Notifier<SessionWorkspaceState> {
SessionWorkspaceController(this.key);

final SessionUiKey key;

@override
SessionWorkspaceState build() => const SessionWorkspaceState();

void update(SessionWorkspaceState Function(SessionWorkspaceState) change) {
state = change(state);
}
}

final sessionWorkspaceStateProvider =
NotifierProvider.family<
SessionWorkspaceController,
SessionWorkspaceState,
SessionUiKey
>(SessionWorkspaceController.new);

final activeSessionUiKeyProvider = Provider<SessionUiKey?>((ref) {
final entryId = ref.watch(selectedRegistrationIdProvider);
final sessionId = ref.watch(activeSessionIdProvider);
if (entryId == null || sessionId == null) return null;
return (entryId: entryId, sessionId: sessionId);
});

void clearSessionWorkspaceState(
ProviderContainer ref,
String entryId,
String sessionId,
) {
ref.invalidate(
sessionWorkspaceStateProvider((entryId: entryId, sessionId: sessionId)),
);
}
115 changes: 109 additions & 6 deletions app/lib/screens/workspace_shell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import '../providers/new_session_picker.dart'
import '../providers/providers.dart';
import '../providers/relay_error_banner.dart';
import '../providers/session_search.dart';
import '../providers/session_workspace_state.dart';
import '../providers/session_setup.dart';
import '../providers/sessions.dart';
import '../providers/supervisor_status.dart';
Expand Down Expand Up @@ -131,6 +132,7 @@ class WorkspaceShellState extends ConsumerState<WorkspaceShell>
/// applied once, while in portrait.
_PanelMode? _panelMode;
bool _prefsApplied = false;
SessionUiKey? _sessionUiKey;
final _mobileScaffoldKey = GlobalKey<ScaffoldState>();

/// Desktop-shaped layout, touch platform only: both the projects sidebar
Expand Down Expand Up @@ -499,8 +501,9 @@ class WorkspaceShellState extends ConsumerState<WorkspaceShell>

// ── Preferences ──────────────────────────────────────────────────────

/// Apply preferences, updating state directly (no setState needed when
/// called from build — the build will use the updated values immediately).
/// Apply preferences, updating the local values immediately. Provider state
/// initialization is deferred when this runs during build because Riverpod
/// forbids notifying listeners while the widget tree is being built.
void _applyPrefs(ProjectPreferences prefs) {
_splitRatio = prefs.splitRatio;
// An unrecognized name resolves to null — unchosen, so the viewport default
Expand All @@ -521,9 +524,53 @@ class WorkspaceShellState extends ConsumerState<WorkspaceShell>
if (idx >= 0 && idx < WorkspaceView.values.length) {
_selectedView = WorkspaceView.values[idx];
}
final key = ref.read(activeSessionUiKeyProvider);
if (key != null) {
final saved = ref.read(sessionWorkspaceStateProvider(key));
if (saved.initialized) {
_restoreSessionUi(saved);
} else {
final selectedView = _selectedView;
final panelMode = _panelMode?.name;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final current = ref.read(sessionWorkspaceStateProvider(key));
if (current.initialized) return;
ref
.read(sessionWorkspaceStateProvider(key).notifier)
.update(
(s) => s.copyWith(
initialized: true,
selectedView: selectedView,
panelMode: panelMode,
),
);
});
}
_sessionUiKey = key;
}
_prefsApplied = true;
}

void _restoreSessionUi(SessionWorkspaceState state) {
_selectedView = state.selectedView;
_panelMode = state.panelMode == null
? null
: _PanelMode.values.asNameMap()[state.panelMode];
_tabletEndDrawerOpen = state.tabletContextOpen;
_tabletContextPanelExpanded = state.tabletContextExpanded;
}

void _updateSessionUi(
SessionWorkspaceState Function(SessionWorkspaceState) change,
) {
final key = _sessionUiKey ?? ref.read(activeSessionUiKeyProvider);
if (key == null) return;
ref
.read(sessionWorkspaceStateProvider(key).notifier)
.update((state) => change(state).copyWith(initialized: true));
}

void _updatePrefs() {
// `projectPreferencesProvider` skips the demo, so `PreferencesService` is
// still bound to the LAST REAL project — a split drag or tab switch inside
Expand Down Expand Up @@ -800,6 +847,40 @@ class WorkspaceShellState extends ConsumerState<WorkspaceShell>

@override
Widget build(BuildContext context) {
ref.listen<SessionUiKey?>(activeSessionUiKeyProvider, (previous, next) {
if (next == null || next == previous) return;
_sessionUiKey = next;
var saved = ref.read(sessionWorkspaceStateProvider(next));
if (!saved.initialized) {
final prefsService = ref.read(preferencesServiceProvider);
// The session can be selected while this project's asynchronous
// preference load is still in flight. `_applyPrefs` will seed it once
// the load lands; using `current` here would copy the previous
// project's layout into the new session.
if (prefsService.projectId != next.entryId) return;
final prefs = prefsService.current;
final idx = prefs.workspaceViewIndex;
saved = saved.copyWith(
initialized: true,
selectedView: idx >= 0 && idx < WorkspaceView.values.length
? WorkspaceView.values[idx]
: WorkspaceView.files,
panelMode: prefs.panelMode,
);
ref
.read(sessionWorkspaceStateProvider(next).notifier)
.update((_) => saved);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || ref.read(activeSessionUiKeyProvider) != next) return;
setState(() => _restoreSessionUi(saved));
if (_pageController.hasClients &&
_pageController.page?.round() != saved.mobilePage) {
_pageController.jumpToPage(saved.mobilePage);
}
});
});

// Subsequent project switches (A → B while WorkspaceShell stays mounted)
// bootstrap via this listener. The *initial* mount is handled by the
// initState post-frame callback below: WorkspaceShell is only built
Expand Down Expand Up @@ -1266,6 +1347,7 @@ class WorkspaceShellState extends ConsumerState<WorkspaceShell>
}

void _publishVisibleSurfaces(int page) {
_updateSessionUi((s) => s.copyWith(mobilePage: page));
ref
.read(agentSurfaceVisibleProvider.notifier)
.set(page == _MobilePage.agent);
Expand Down Expand Up @@ -1363,6 +1445,7 @@ class WorkspaceShellState extends ConsumerState<WorkspaceShell>
void _selectView(WorkspaceView view) {
setState(() {
_selectedView = view;
_updateSessionUi((s) => s.copyWith(selectedView: view));
_updatePrefs();
});
ref.read(visibleWorkspaceViewProvider.notifier).set(view);
Expand All @@ -1381,7 +1464,10 @@ class WorkspaceShellState extends ConsumerState<WorkspaceShell>
return;
}
if (_effectivePanelMode == _PanelMode.contextHidden) {
setState(() => _panelMode = _PanelMode.normal);
setState(() {
_panelMode = _PanelMode.normal;
_updateSessionUi((s) => s.copyWith(panelMode: _panelMode!.name));
});
}
}

Expand Down Expand Up @@ -1815,22 +1901,31 @@ class WorkspaceShellState extends ConsumerState<WorkspaceShell>
}

void _setTabletContextExpanded(bool expanded) {
setState(() => _tabletContextPanelExpanded = expanded);
setState(() {
_tabletContextPanelExpanded = expanded;
_updateSessionUi((s) => s.copyWith(tabletContextExpanded: expanded));
});
}

/// Opens the touch tablet's context pane — reached only from
/// [_openContextPanel]'s tablet branch, since no fling opens the pane (see
/// [_tabletFlingLeftward]).
void _openTabletContextPanel() {
if (_tabletEndDrawerOpen) return;
setState(() => _tabletEndDrawerOpen = true);
setState(() {
_tabletEndDrawerOpen = true;
_updateSessionUi((s) => s.copyWith(tabletContextOpen: true));
});
}

/// Closes the touch tablet's context pane — the shared tail of its two
/// close paths ([_closeTabletDrawers]'s back handler and the close button in
/// the pane's own tab bar).
void _closeTabletContextPanel() {
setState(() => _tabletEndDrawerOpen = false);
setState(() {
_tabletEndDrawerOpen = false;
_updateSessionUi((s) => s.copyWith(tabletContextOpen: false));
});
}

/// Closes whichever of the touch tablet's sidebar/context pane is open —
Expand Down Expand Up @@ -1907,6 +2002,7 @@ class WorkspaceShellState extends ConsumerState<WorkspaceShell>
_panelMode = _effectivePanelMode == _PanelMode.contextHidden
? _PanelMode.normal
: _PanelMode.contextHidden;
_updateSessionUi((s) => s.copyWith(panelMode: _panelMode!.name));
_updatePrefs();
});
}
Expand All @@ -1917,6 +2013,7 @@ class WorkspaceShellState extends ConsumerState<WorkspaceShell>
void _hideContextPanel() {
setState(() {
_panelMode = _PanelMode.contextHidden;
_updateSessionUi((s) => s.copyWith(panelMode: _panelMode!.name));
_updatePrefs();
});
}
Expand Down Expand Up @@ -1961,6 +2058,9 @@ class WorkspaceShellState extends ConsumerState<WorkspaceShell>
isExpanded: false,
onToggleExpand: () => setState(() {
_panelMode = _PanelMode.contextExpanded;
_updateSessionUi(
(s) => s.copyWith(panelMode: _panelMode!.name),
);
_updatePrefs();
}),
onClose: _hideContextPanel,
Expand Down Expand Up @@ -1992,6 +2092,9 @@ class WorkspaceShellState extends ConsumerState<WorkspaceShell>
isExpanded: true,
onToggleExpand: () => setState(() {
_panelMode = _PanelMode.normal;
_updateSessionUi(
(s) => s.copyWith(panelMode: _panelMode!.name),
);
_updatePrefs();
}),
onClose: _hideContextPanel,
Expand Down
3 changes: 3 additions & 0 deletions app/lib/widgets/session_row.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import '../providers/open_checkout.dart';
import '../providers/project_work_status.dart';
import '../providers/providers.dart';
import '../providers/session_delete_pending.dart';
import '../providers/session_workspace_state.dart';
import '../providers/session_setup.dart';
import '../providers/sessions.dart';
import '../providers/ui_attention_providers.dart';
Expand Down Expand Up @@ -847,6 +848,7 @@ class _SessionMenu extends ConsumerWidget {
final archived = await svc.archive(session.id);
if (archived != null) {
clearChatComposerDraft(ref, session.id);
clearSessionWorkspaceState(ref, entryId, session.id);
}
_disconnectIfEmpty(ref);
case _SessionAction.delete:
Expand Down Expand Up @@ -914,6 +916,7 @@ class _SessionMenu extends ConsumerWidget {
);
if (result == SessionDeleteResult.deleted) {
clearChatComposerDraft(ref, capturedId);
clearSessionWorkspaceState(ref, entryId, capturedId);
_disconnectIfEmpty(ref);
}
}
Expand Down
Loading