From 5ce9c3b4810331d77926736fb6ee634416281f38 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 5 May 2026 11:19:50 -0700 Subject: [PATCH] feat(tasks): remember tasks page resume state across sessions (#1442) Persist transient Tasks page position (GitHub mode, active preset/query, Linear preset/query) in PersistedUIState so reopening Tasks restores the user's working context instead of falling back to defaults. Source, repo selection, team selection, and active project keep using their existing settings paths. Co-authored-by: Orca --- docs/tasks-page-resume-state.md | 176 +++++++++++++++++++++++ src/renderer/src/components/TaskPage.tsx | 146 +++++++++++++++---- src/renderer/src/store/slices/ui.test.ts | 42 +++++- src/renderer/src/store/slices/ui.ts | 99 ++++++++++++- src/shared/types.ts | 13 ++ 5 files changed, 443 insertions(+), 33 deletions(-) create mode 100644 docs/tasks-page-resume-state.md diff --git a/docs/tasks-page-resume-state.md b/docs/tasks-page-resume-state.md new file mode 100644 index 000000000..64589b277 --- /dev/null +++ b/docs/tasks-page-resume-state.md @@ -0,0 +1,176 @@ +# Tasks Page Resume State + +## Goal + +When a user leaves the Tasks page and comes back later, Orca should reopen the page in the same working context instead of falling back to a generic GitHub Issues/PRs list. + +The resume behavior should cover: + +- GitHub vs Linear. +- GitHub Issues/PRs vs GitHub Project mode. +- The selected GitHub Issues/PRs preset or custom search. +- The selected GitHub Project and Project view. +- The selected repos for GitHub Issues/PRs. +- The selected Linear teams. + +This should be lightweight. It should not introduce a second project-selection model or duplicate state that is already persisted elsewhere. + +## Existing Persisted State + +The current settings model already remembers most durable task choices: + +- `settings.defaultTaskSource` remembers GitHub vs Linear. +- `settings.defaultRepoSelection` remembers GitHub repo selection for cross-repo Issues/PRs. +- `settings.defaultLinearTeamSelection` remembers Linear team selection. +- `settings.githubProjects.activeProject` remembers the active GitHub Project. +- `settings.githubProjects.lastViewByProject` remembers the last selected Project view per Project. +- `settings.defaultTaskViewPreset` remembers the user's default GitHub Issues/PRs preset. + +These should stay in place. They are already wired into the page and, for repo/team/project selection, they represent actual user preferences rather than purely transient page state. + +## Missing State + +The missing piece is the user's current page position inside Tasks: + +- Whether GitHub is showing `Issues/PRs` or `Project`. +- Which GitHub Issues/PRs preset is currently active. +- The currently applied GitHub Issues/PRs query when the user has typed a custom search. +- Which Linear preset is currently active. +- The currently applied Linear query when the user has typed a search. + +Linear presets are visible task tabs and drive the fetch path, so they must be restored too. + +## Proposed Shape + +Add a small optional field to `PersistedUIState`: + +```ts +export type TaskResumeState = { + githubMode?: 'items' | 'project' + githubItemsPreset?: TaskViewPresetId | null + githubItemsQuery?: string + linearPreset?: 'assigned' | 'created' | 'all' | 'completed' + linearQuery?: string +} +``` + +Then add: + +```ts +taskResumeState?: TaskResumeState +``` + +to `PersistedUIState`. + +Why `PersistedUIState`: this is page-position UI state, similar to sidebar filters and widths. It is not an app-wide setting and should not be presented as a configurable default. + +Do not add `source` to `TaskResumeState`. `settings.defaultTaskSource` already represents the user's last-used task source today, and duplicating it would create two persisted sources of truth. Keep source changes on the existing settings path. + +Do not reset `githubMode` just because the current source is Linear. Source selection and GitHub sub-mode are independent pieces of context: if the user was in GitHub Project mode, switched to Linear, then later switches back to GitHub, GitHub should still reopen in Project mode. + +## Restore Rules + +On Tasks page mount: + +1. If `taskPageData.taskSource` was passed by a caller, use it. Explicit navigation intent should win. +2. Otherwise fall back to `settings.defaultTaskSource`. +3. Apply these restore rules only after both settings and persisted UI state have hydrated. The current app loads those asynchronously before setting `persistedUIReady`; a `useState` initializer that reads `settings === null` or an unhydrated `taskResumeState` will capture defaults and miss the restored context. + +The restore should run once per Tasks page mount, after hydration is ready. It should not keep reapplying persisted state over local user interactions while the page remains open. + +For GitHub: + +1. Restore `githubMode` from `taskResumeState.githubMode`. +2. If mode is `items`, restore `githubItemsPreset` and `githubItemsQuery`. +3. Repo selection continues to come from `settings.defaultRepoSelection`. +4. If mode is `project`, use `settings.githubProjects.activeProject` and `settings.githubProjects.lastViewByProject`. +5. If Project mode has no active project or view, show the Project picker empty state instead of silently switching to Issues/PRs. + +For Linear: + +1. Restore `linearPreset`. +2. Restore `linearQuery`. +3. Team selection continues to come from `settings.defaultLinearTeamSelection`. + +Fallback behavior: + +- If resume state is absent, use today's defaults. +- If `githubItemsPreset` is non-null, derive the query from the preset unless `githubItemsQuery` disagrees because of a future migration. The preset should be treated as authoritative. +- If `githubItemsPreset` is null, use `githubItemsQuery` as a custom search. +- If `linearQuery` is non-empty, treat it as custom search and keep the restored `linearPreset` as the preset to return to after the search is cleared. The visible active preset should be suppressed while the search input is non-empty, matching today's UI. + +## Write Rules + +Update `taskResumeState` only when the user changes the active page context: + +- Switching GitHub mode between Issues/PRs and Project. +- Clicking a GitHub Issues/PRs preset. +- Applying the debounced GitHub Issues/PRs search. +- Clearing the GitHub Issues/PRs search. +- Clicking a Linear preset. +- Applying the debounced Linear search. +- Clearing the Linear search. + +Do not persist every raw search keystroke. The current UI applies search after a 300 ms debounce, so the persisted value should follow the debounced applied query, not the raw input. A user who types, waits for results, leaves Tasks, and comes back should see the same applied query. + +Task source, repo selection, Linear team selection, active Project, and active Project view should continue writing through their existing settings paths. + +The resume setter should merge partial updates with the existing resume state so changing one dimension does not erase the others. It should persist via `window.api.ui.set({ taskResumeState })` after updating local Zustand state. + +When writing preset selections, clear stale custom-query ambiguity explicitly: + +- GitHub preset click: write `{ githubItemsPreset: presetId, githubItemsQuery: undefined }` or the canonical preset query. Restore must still use the preset as authoritative. +- GitHub custom search apply/debounce: write `{ githubItemsPreset: null, githubItemsQuery: trimmedQuery }`. +- GitHub search clear: write `{ githubItemsPreset: null, githubItemsQuery: '' }`. +- Linear preset click: write `{ linearPreset: presetId, linearQuery: '' }`. +- Linear custom search apply/debounce: write `{ linearQuery: trimmedQuery }` without changing `linearPreset`. +- Linear search clear: write `{ linearQuery: '' }` without changing `linearPreset`. + +Do not persist mode changes caused only by hiding Project mode while another source is active. In practice, avoid an effect that changes persisted `githubMode` from `project` to `items` when `taskSource !== 'github'`. + +## Implementation Notes + +Primary files: + +- `src/shared/types.ts`: add `TaskResumeState` and `taskResumeState?: TaskResumeState`. +- `src/shared/constants.ts`: no required default beyond leaving the field absent. If a concrete default is preferred, use GitHub Issues/PRs with the existing default preset. +- `src/renderer/src/store/slices/ui.ts`: hydrate `taskResumeState` defensively, expose a setter such as `setTaskResumeState`, and use it in `openTaskPage` prefetch selection. +- `src/renderer/src/components/TaskPage.tsx`: initialize source from existing settings/page data, and initialize GitHub mode, GitHub item preset/query, Linear preset, and Linear query from the resume state. +- `src/renderer/src/App.tsx`: no new work should be needed if the field rides the existing `ui:get` hydration. + +The setter should call `window.api.ui.set({ taskResumeState })` through the same persistence path used by other persisted UI fields, or follow the existing store pattern if there is already a central UI save effect for the relevant slice. + +Hydration must sanitize the nested object because `PersistedUIState` comes from disk. Invalid `githubMode`, invalid GitHub preset ids, invalid Linear preset ids, or non-string queries should fall back field-by-field rather than entering Zustand as untrusted values. + +`TaskPage` should subscribe to `persistedUIReady`, `settings`, and `taskResumeState`, then perform a one-shot local initialization once both settings and UI hydration are available. This avoids the current class of bugs where `useState(settings?.defaultTaskViewPreset ?? 'all')` captures `'all'` before settings arrive. + +`openTaskPage` currently prefetches the settings default GitHub preset. After this change, it should prefetch the query the Tasks page will actually mount with: + +1. If explicit `taskPageData.taskSource` is `linear`, skip GitHub prefetch. +2. If explicit `taskPageData.taskSource` is `github`, or the resolved source is GitHub, prefetch only when the resolved GitHub mode is `items`. +3. If `githubItemsPreset` is non-null, prefetch that preset query. +4. If `githubItemsPreset` is null and `githubItemsQuery` is non-empty, prefetch the custom query. +5. Otherwise prefetch `settings.defaultTaskViewPreset`. + +The prefetch should also use the same repo selection the page will use: `taskPageData.preselectedRepoId` when present, otherwise `settings.defaultRepoSelection`, otherwise all eligible repos. Warming only `activeRepoId` is acceptable as an optimization fallback, but it is not equivalent to the mounted cross-repo query. + +## Non-Goals + +Do not remember scroll position, selected table row, open dialogs, pagination page, Project search overrides, or cached API results in this pass. + +Those states are more fragile because they depend on live remote data. The first version should only restore the user's broad working context. + +## Acceptance Criteria + +- Open Tasks, switch to Linear, leave Tasks, return to Tasks: Linear is selected. +- Open Tasks, switch to GitHub Project mode, switch to Linear, leave Tasks, return to Tasks, then switch back to GitHub: GitHub is still in Project mode. +- Open Tasks, switch to GitHub Project mode, select a Project view, leave Tasks, return to Tasks: Project mode opens on the same Project view. +- Open Tasks, choose GitHub Issues/PRs `My PRs`, leave Tasks, return to Tasks: GitHub Issues/PRs opens with `My PRs` active. +- Open Tasks, type and apply a custom GitHub search, leave Tasks, return to Tasks: GitHub Issues/PRs opens with that custom query and no preset selected. +- Open Tasks, type a GitHub search, wait for the debounced results, leave Tasks, return to Tasks: GitHub Issues/PRs opens with that applied query and no preset selected. +- Open Tasks, choose Linear `Completed`, leave Tasks, return to Tasks: Linear opens with `Completed` active. +- Open Tasks, type a Linear search, wait for the debounced results, leave Tasks, return to Tasks: Linear opens with that applied query. +- Open Tasks before hydration has completed, then wait for hydration: Tasks applies the persisted source/mode/preset/query exactly once and does not overwrite subsequent user changes. +- Corrupt `taskResumeState` on disk with invalid modes/preset ids/non-string queries, restart, and open Tasks: invalid fields are ignored field-by-field without breaking the page. +- Existing repo selection, Linear team selection, and GitHub Project view persistence keep working unchanged. +- Restart the app after each source/mode/preset/custom-query scenario above: the same Tasks context is restored from persisted UI/settings state. diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 1badaee6d..10e431fca 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -653,6 +653,9 @@ const hasUpstreamCandidateDivergence = ( export default function TaskPage(): React.JSX.Element { const settings = useAppStore((s) => s.settings) + const persistedUIReady = useAppStore((s) => s.persistedUIReady) + const taskResumeState = useAppStore((s) => s.taskResumeState) + const setTaskResumeState = useAppStore((s) => s.setTaskResumeState) const pageData = useAppStore((s) => s.taskPageData) const closeTaskPage = useAppStore((s) => s.closeTaskPage) const activeModal = useAppStore((s) => s.activeModal) @@ -768,6 +771,10 @@ export default function TaskPage(): React.JSX.Element { const defaultTaskSource = settings?.defaultTaskSource ?? 'github' const [taskSource, setTaskSource] = useState(pageData.taskSource ?? defaultTaskSource) + const taskResumeAppliedRef = useRef(false) + const githubSearchPersistReadyRef = useRef(false) + const linearSearchPersistReadyRef = useRef(false) + const [taskResumeApplied, setTaskResumeApplied] = useState(false) // Why: pageData.taskSource changes when the user clicks a specific source // icon in the sidebar while the task page is already open. useState only @@ -778,27 +785,11 @@ export default function TaskPage(): React.JSX.Element { } }, [pageData.taskSource]) - // Why: settings load asynchronously — the useState initializer may capture - // null settings on fast navigation. Sync once settings arrive, but only - // when no explicit source was passed via sidebar icon click. - useEffect(() => { - if (!pageData.taskSource && settings?.defaultTaskSource) { - setTaskSource(settings.defaultTaskSource) - } - }, [settings?.defaultTaskSource, pageData.taskSource]) - // Why: Project mode is a sub-tab within the GitHub source. Visible whenever // the user is on the GitHub task source — actual entry into Project mode is // gated on a non-null `activeProject` once they pick one. const projectModeVisible = taskSource === 'github' const [githubMode, setGithubMode] = useState<'items' | 'project'>('items') - useEffect(() => { - // Snap back to items if the user leaves the GitHub task source while - // sitting in Project mode. - if (!projectModeVisible && githubMode === 'project') { - setGithubMode('items') - } - }, [projectModeVisible, githubMode]) const [taskSearchInput, setTaskSearchInput] = useState(initialTaskQuery) const [appliedTaskSearch, setAppliedTaskSearch] = useState(initialTaskQuery) @@ -1033,9 +1024,47 @@ export default function TaskPage(): React.JSX.Element { const [linearLoading, setLinearLoading] = useState(false) const [linearError, setLinearError] = useState(null) const [linearSearchInput, setLinearSearchInput] = useState('') + const [appliedLinearSearch, setAppliedLinearSearch] = useState('') const [activeLinearPreset, setActiveLinearPreset] = useState('all') const [linearRefreshNonce, setLinearRefreshNonce] = useState(0) + useEffect(() => { + if (taskResumeAppliedRef.current || !persistedUIReady || !settings) { + return + } + + setTaskSource(pageData.taskSource ?? settings.defaultTaskSource) + setRepoSelection(resolvedInitialSelection) + + const nextGithubMode = taskResumeState?.githubMode ?? 'items' + setGithubMode(nextGithubMode) + + const preset = taskResumeState?.githubItemsPreset + if (preset === null) { + const query = taskResumeState?.githubItemsQuery ?? '' + setTaskSearchInput(query) + setAppliedTaskSearch(query) + setActiveTaskPreset(null) + } else { + const presetId = preset ?? settings.defaultTaskViewPreset + const query = getTaskPresetQuery(presetId) + setTaskSearchInput(query) + setAppliedTaskSearch(query) + setActiveTaskPreset(presetId) + } + + const linearPreset = taskResumeState?.linearPreset ?? 'all' + const linearQuery = taskResumeState?.linearQuery ?? '' + setActiveLinearPreset(linearPreset) + setLinearSearchInput(linearQuery) + setAppliedLinearSearch(linearQuery) + + // Why: settings and persisted UI hydrate asynchronously. Apply the restored + // Tasks context exactly once so later source/filter clicks remain local. + taskResumeAppliedRef.current = true + setTaskResumeApplied(true) + }, [persistedUIReady, settings, pageData.taskSource, resolvedInitialSelection, taskResumeState]) + // Why: fetch the full team list from the Linear API so the selector shows // all teams the user belongs to, not just teams with issues in the current // fetch window. Fetched once when the Linear tab is active and connected. @@ -1044,6 +1073,9 @@ export default function TaskPage(): React.JSX.Element { ) useEffect(() => { + if (!taskResumeApplied) { + return + } if (taskSource !== 'linear' || !linearStatus.connected) { return } @@ -1054,7 +1086,7 @@ export default function TaskPage(): React.JSX.Element { console.warn('[TaskPage] Failed to fetch Linear teams') }) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [taskSource, linearStatus.connected]) + }, [taskSource, linearStatus.connected, taskResumeApplied]) const defaultLinearTeamSelection = settings?.defaultLinearTeamSelection const [linearTeamSelection, setLinearTeamSelection] = useState>(() => { @@ -1197,19 +1229,42 @@ export default function TaskPage(): React.JSX.Element { ) useEffect(() => { + if (!taskResumeApplied) { + return + } const timeout = window.setTimeout(() => { setAppliedTaskSearch(taskSearchInput) }, TASK_SEARCH_DEBOUNCE_MS) return () => window.clearTimeout(timeout) - }, [taskSearchInput]) + }, [taskSearchInput, taskResumeApplied]) useEffect(() => { + if (!taskResumeApplied) { + return + } + if (!githubSearchPersistReadyRef.current) { + githubSearchPersistReadyRef.current = true + return + } + if (activeTaskPreset !== null) { + return + } + setTaskResumeState({ + githubItemsPreset: null, + githubItemsQuery: appliedTaskSearch.trim() + }) + }, [activeTaskPreset, appliedTaskSearch, setTaskResumeState, taskResumeApplied]) + + useEffect(() => { + if (!taskResumeApplied) { + return + } // Why: both early-return branches must clear `retryingRepoPaths` — if the // user clicks Retry and then switches `taskSource` away from 'github' (or // somehow ends up with zero repos selected) before the fetch dispatches, // neither the `.then` nor the `.catch` below will fire, and the Retry // button would stay stuck in its disabled/Retrying state indefinitely. - if (taskSource !== 'github') { + if (taskSource !== 'github' || githubMode !== 'items') { setRetryingRepoPaths(new Set()) return } @@ -1347,15 +1402,24 @@ export default function TaskPage(): React.JSX.Element { // updates. `workItemsInvalidationNonce` is explicitly included so a // preference flip (which only evicts cache) re-dispatches this effect. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedRepos, appliedTaskSearch, taskRefreshNonce, taskSource, workItemsInvalidationNonce]) + }, [ + selectedRepos, + appliedTaskSearch, + taskRefreshNonce, + taskSource, + githubMode, + workItemsInvalidationNonce, + taskResumeApplied + ]) const handleApplyTaskSearch = useCallback((): void => { const trimmed = taskSearchInput.trim() setTaskSearchInput(trimmed) setAppliedTaskSearch(trimmed) setActiveTaskPreset(null) + setTaskResumeState({ githubItemsPreset: null, githubItemsQuery: trimmed }) setTaskRefreshNonce((current) => current + 1) - }, [taskSearchInput]) + }, [setTaskResumeState, taskSearchInput]) const handleTaskSearchChange = useCallback((event: React.ChangeEvent): void => { const next = event.target.value @@ -1605,18 +1669,34 @@ export default function TaskPage(): React.JSX.Element { // Why: debounce the Linear search input so we don't fire a request on every // keystroke — matches the 300ms cadence used for GitHub search. - const [appliedLinearSearch, setAppliedLinearSearch] = useState('') useEffect(() => { + if (!taskResumeApplied) { + return + } const timeout = window.setTimeout(() => { setAppliedLinearSearch(linearSearchInput) }, TASK_SEARCH_DEBOUNCE_MS) return () => window.clearTimeout(timeout) - }, [linearSearchInput]) + }, [linearSearchInput, taskResumeApplied]) + + useEffect(() => { + if (!taskResumeApplied) { + return + } + if (!linearSearchPersistReadyRef.current) { + linearSearchPersistReadyRef.current = true + return + } + setTaskResumeState({ linearQuery: appliedLinearSearch.trim() }) + }, [appliedLinearSearch, setTaskResumeState, taskResumeApplied]) // Why: fetch Linear issues when the tab is active and the account is // connected. An empty search falls back to `listLinearIssues` (assigned // issues) so the default view shows the user's own work. useEffect(() => { + if (!taskResumeApplied) { + return + } if (taskSource !== 'linear') { return } @@ -1661,7 +1741,8 @@ export default function TaskPage(): React.JSX.Element { linearStatus.connected, appliedLinearSearch, activeLinearPreset, - linearRefreshNonce + linearRefreshNonce, + taskResumeApplied ]) // Why: for Linear issues the "Use" flow opens the composer with the issue @@ -1866,7 +1947,10 @@ export default function TaskPage(): React.JSX.Element {