diff --git a/app/lib/providers/recent_sessions.dart b/app/lib/providers/recent_sessions.dart index 555631aa..4c8def5e 100644 --- a/app/lib/providers/recent_sessions.dart +++ b/app/lib/providers/recent_sessions.dart @@ -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'; @@ -470,6 +471,7 @@ Future 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, @@ -530,6 +532,7 @@ Future 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; } diff --git a/app/lib/providers/session_workspace_state.dart b/app/lib/providers/session_workspace_state.dart new file mode 100644 index 00000000..5f13f114 --- /dev/null +++ b/app/lib/providers/session_workspace_state.dart @@ -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 { + 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((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)), + ); +} diff --git a/app/lib/screens/workspace_shell.dart b/app/lib/screens/workspace_shell.dart index b5833edc..5dec2b96 100644 --- a/app/lib/screens/workspace_shell.dart +++ b/app/lib/screens/workspace_shell.dart @@ -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'; @@ -131,6 +132,7 @@ class WorkspaceShellState extends ConsumerState /// applied once, while in portrait. _PanelMode? _panelMode; bool _prefsApplied = false; + SessionUiKey? _sessionUiKey; final _mobileScaffoldKey = GlobalKey(); /// Desktop-shaped layout, touch platform only: both the projects sidebar @@ -499,8 +501,9 @@ class WorkspaceShellState extends ConsumerState // ── 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 @@ -521,9 +524,53 @@ class WorkspaceShellState extends ConsumerState 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 @@ -800,6 +847,40 @@ class WorkspaceShellState extends ConsumerState @override Widget build(BuildContext context) { + ref.listen(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 @@ -1266,6 +1347,7 @@ class WorkspaceShellState extends ConsumerState } void _publishVisibleSurfaces(int page) { + _updateSessionUi((s) => s.copyWith(mobilePage: page)); ref .read(agentSurfaceVisibleProvider.notifier) .set(page == _MobilePage.agent); @@ -1363,6 +1445,7 @@ class WorkspaceShellState extends ConsumerState void _selectView(WorkspaceView view) { setState(() { _selectedView = view; + _updateSessionUi((s) => s.copyWith(selectedView: view)); _updatePrefs(); }); ref.read(visibleWorkspaceViewProvider.notifier).set(view); @@ -1381,7 +1464,10 @@ class WorkspaceShellState extends ConsumerState return; } if (_effectivePanelMode == _PanelMode.contextHidden) { - setState(() => _panelMode = _PanelMode.normal); + setState(() { + _panelMode = _PanelMode.normal; + _updateSessionUi((s) => s.copyWith(panelMode: _panelMode!.name)); + }); } } @@ -1815,7 +1901,10 @@ class WorkspaceShellState extends ConsumerState } 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 @@ -1823,14 +1912,20 @@ class WorkspaceShellState extends ConsumerState /// [_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 — @@ -1907,6 +2002,7 @@ class WorkspaceShellState extends ConsumerState _panelMode = _effectivePanelMode == _PanelMode.contextHidden ? _PanelMode.normal : _PanelMode.contextHidden; + _updateSessionUi((s) => s.copyWith(panelMode: _panelMode!.name)); _updatePrefs(); }); } @@ -1917,6 +2013,7 @@ class WorkspaceShellState extends ConsumerState void _hideContextPanel() { setState(() { _panelMode = _PanelMode.contextHidden; + _updateSessionUi((s) => s.copyWith(panelMode: _panelMode!.name)); _updatePrefs(); }); } @@ -1961,6 +2058,9 @@ class WorkspaceShellState extends ConsumerState isExpanded: false, onToggleExpand: () => setState(() { _panelMode = _PanelMode.contextExpanded; + _updateSessionUi( + (s) => s.copyWith(panelMode: _panelMode!.name), + ); _updatePrefs(); }), onClose: _hideContextPanel, @@ -1992,6 +2092,9 @@ class WorkspaceShellState extends ConsumerState isExpanded: true, onToggleExpand: () => setState(() { _panelMode = _PanelMode.normal; + _updateSessionUi( + (s) => s.copyWith(panelMode: _panelMode!.name), + ); _updatePrefs(); }), onClose: _hideContextPanel, diff --git a/app/lib/widgets/session_row.dart b/app/lib/widgets/session_row.dart index e322875e..8a21ef8a 100644 --- a/app/lib/widgets/session_row.dart +++ b/app/lib/widgets/session_row.dart @@ -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'; @@ -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: @@ -914,6 +916,7 @@ class _SessionMenu extends ConsumerWidget { ); if (result == SessionDeleteResult.deleted) { clearChatComposerDraft(ref, capturedId); + clearSessionWorkspaceState(ref, entryId, capturedId); _disconnectIfEmpty(ref); } } diff --git a/app/lib/widgets/terminal_list_view.dart b/app/lib/widgets/terminal_list_view.dart index 6a656b7c..5b0d0898 100644 --- a/app/lib/widgets/terminal_list_view.dart +++ b/app/lib/widgets/terminal_list_view.dart @@ -17,6 +17,7 @@ import '../models/terminal_models.dart'; import '../navigation/back_intent.dart'; import '../providers/providers.dart'; import '../providers/sessions.dart'; +import '../providers/session_workspace_state.dart'; import '../providers/visible_surface.dart'; import '../services/terminal_service.dart'; import 'terminal_detail_view.dart'; @@ -40,8 +41,40 @@ class TerminalListView extends ConsumerStatefulWidget { class _TerminalListViewState extends ConsumerState { static const int _maxAdHocTerminals = 10; - String? _pinnedTerminalId; - String? _pushedTerminalId; + SessionUiKey? get _uiKey => ref.read(activeSessionUiKeyProvider); + + SessionWorkspaceState get _uiState { + final key = _uiKey; + return key == null + ? const SessionWorkspaceState() + : ref.read(sessionWorkspaceStateProvider(key)); + } + + String? get _pinnedTerminalId => _uiState.pinnedTerminalId; + String? get _pushedTerminalId => _uiState.pushedTerminalId; + + void _updateTerminalUi( + SessionWorkspaceState Function(SessionWorkspaceState) change, + ) { + final key = _uiKey; + if (key == null) return; + _updateTerminalUiFor(key, change); + } + + void _updateTerminalUiFor( + SessionUiKey key, + SessionWorkspaceState Function(SessionWorkspaceState) change, + ) { + ref.read(sessionWorkspaceStateProvider(key).notifier).update(change); + } + + void _setPinnedTerminal(String? id) => _updateTerminalUi( + (s) => s.copyWith(pinnedTerminalId: id, clearPinnedTerminalId: id == null), + ); + + void _setPushedTerminal(String? id) => _updateTerminalUi( + (s) => s.copyWith(pushedTerminalId: id, clearPushedTerminalId: id == null), + ); /// The PTYs carrying a checkout's `worktree.setup` transcript. /// @@ -89,12 +122,14 @@ class _TerminalListViewState extends ConsumerState { return false; } if (_pushedTerminalId == null) return false; - setState(() => _pushedTerminalId = null); + _setPushedTerminal(null); return true; } @override Widget build(BuildContext context) { + final key = ref.watch(activeSessionUiKeyProvider); + if (key != null) ref.watch(sessionWorkspaceStateProvider(key)); final terminalService = serviceWhenReady(ref, terminalServiceProvider); if (terminalService == null) { return const AbLoading(message: 'loading terminals...'); @@ -108,11 +143,11 @@ class _TerminalListViewState extends ConsumerState { priority: BackPriority.pushedTerminal, active: onScreen && _pushedTerminalId != null, onBack: _backFromPushed, - child: _buildBody(terminalService), + child: _buildBody(terminalService, key), ); } - Widget _buildBody(TerminalService terminalService) { + Widget _buildBody(TerminalService terminalService, SessionUiKey? key) { final tabs = _adHocTerminals; // Push navigation — fullscreen terminal output. @@ -129,9 +164,14 @@ class _TerminalListViewState extends ConsumerState { if (fullTab != null) { return _buildPushedView(fullTab, terminalService); } - WidgetsBinding.instance.addPostFrameCallback( - (_) => setState(() => _pushedTerminalId = null), - ); + if (key != null) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _updateTerminalUiFor( + key, + (s) => s.copyWith(clearPushedTerminalId: true), + ), + ); + } return const SizedBox.shrink(); } return _buildPushedView(tab, terminalService); @@ -143,9 +183,14 @@ class _TerminalListViewState extends ConsumerState { .where((t) => t.terminalId == _pinnedTerminalId) .firstOrNull; if (pinnedTab == null) { - WidgetsBinding.instance.addPostFrameCallback( - (_) => setState(() => _pinnedTerminalId = null), - ); + if (key != null) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _updateTerminalUiFor( + key, + (s) => s.copyWith(clearPinnedTerminalId: true), + ), + ); + } return const SizedBox.shrink(); } final remaining = tabs @@ -182,7 +227,7 @@ class _TerminalListViewState extends ConsumerState { final id = _nextAdHocTerminalId(existingIds); final name = _terminalName(id); service.createAdHocTerminal(id, name: name); - setState(() => _pushedTerminalId = id); + _setPushedTerminal(id); }, ), ); @@ -204,7 +249,7 @@ class _TerminalListViewState extends ConsumerState { final id = _nextAdHocTerminalId(existingIds); final name = _terminalName(id); service.createAdHocTerminal(id, name: name); - setState(() => _pushedTerminalId = id); + _setPushedTerminal(id); }, ), ], @@ -252,17 +297,15 @@ class _TerminalListViewState extends ConsumerState { AbRowAction( icon: AbIcons.pin, tooltip: 'Pin', - onTap: () => setState(() => _pinnedTerminalId = tab.terminalId), + onTap: () => _setPinnedTerminal(tab.terminalId), ), AbRowAction( icon: AbIcons.trash, tooltip: 'Delete', tone: AbIconButtonTone.danger, onTap: () { - setState(() { - if (_pinnedTerminalId == tab.terminalId) _pinnedTerminalId = null; - if (_pushedTerminalId == tab.terminalId) _pushedTerminalId = null; - }); + if (_pinnedTerminalId == tab.terminalId) _setPinnedTerminal(null); + if (_pushedTerminalId == tab.terminalId) _setPushedTerminal(null); service.deleteTerminal(tab.terminalId); }, ), @@ -272,7 +315,7 @@ class _TerminalListViewState extends ConsumerState { onTap: () { // Focusing the terminal clears its unread badge. service.setActiveTerminal(tab.terminalId); - setState(() => _pushedTerminalId = tab.terminalId); + _setPushedTerminal(tab.terminalId); }, ); } @@ -283,10 +326,10 @@ class _TerminalListViewState extends ConsumerState { return TerminalDetailView( tab: tab, terminalService: service, - onBack: () => setState(() => _pushedTerminalId = null), + onBack: () => _setPushedTerminal(null), onDelete: () { final id = tab.terminalId; - setState(() => _pushedTerminalId = null); + _setPushedTerminal(null); service.deleteTerminal(id); }, ); @@ -327,7 +370,7 @@ class _TerminalListViewState extends ConsumerState { AbIconButton( icon: AbIcons.unpin, tooltip: 'Unpin', - onTap: () => setState(() => _pinnedTerminalId = null), + onTap: () => _setPinnedTerminal(null), ), AbIconButton( icon: AbIcons.trash, @@ -335,7 +378,7 @@ class _TerminalListViewState extends ConsumerState { tone: AbIconButtonTone.danger, onTap: () { final id = pinnedTab.terminalId; - setState(() => _pinnedTerminalId = null); + _setPinnedTerminal(null); service.deleteTerminal(id); }, ), diff --git a/app/test/providers/session_workspace_state_test.dart b/app/test/providers/session_workspace_state_test.dart new file mode 100644 index 00000000..1b59effe --- /dev/null +++ b/app/test/providers/session_workspace_state_test.dart @@ -0,0 +1,65 @@ +import 'package:antgrid/models/workspace_view.dart'; +import 'package:antgrid/providers/session_workspace_state.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('workspace presentation is isolated by project and session', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + const first = (entryId: 'project-a', sessionId: 'session-1'); + const second = (entryId: 'project-a', sessionId: 'session-2'); + const otherProject = (entryId: 'project-b', sessionId: 'session-1'); + + container + .read(sessionWorkspaceStateProvider(first).notifier) + .update( + (s) => s.copyWith( + initialized: true, + selectedView: WorkspaceView.terminals, + panelMode: 'contextHidden', + pushedTerminalId: 'terminal-1', + ), + ); + + expect( + container.read(sessionWorkspaceStateProvider(first)).selectedView, + WorkspaceView.terminals, + ); + expect( + container.read(sessionWorkspaceStateProvider(first)).pushedTerminalId, + 'terminal-1', + ); + expect( + container.read(sessionWorkspaceStateProvider(second)).selectedView, + WorkspaceView.files, + ); + expect( + container.read(sessionWorkspaceStateProvider(otherProject)).panelMode, + isNull, + ); + }); + + test('clearing a deleted session does not affect its sibling', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + const removed = (entryId: 'project-a', sessionId: 'removed'); + const sibling = (entryId: 'project-a', sessionId: 'sibling'); + + for (final key in [removed, sibling]) { + container + .read(sessionWorkspaceStateProvider(key).notifier) + .update((s) => s.copyWith(panelMode: 'contextHidden')); + } + clearSessionWorkspaceState(container, removed.entryId, removed.sessionId); + + expect( + container.read(sessionWorkspaceStateProvider(removed)).panelMode, + isNull, + ); + expect( + container.read(sessionWorkspaceStateProvider(sibling)).panelMode, + 'contextHidden', + ); + }); +}