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 <help@stably.ai>
This commit is contained in:
Jinjing 2026-05-05 11:19:50 -07:00 committed by GitHub
parent 14e879a825
commit 5ce9c3b481
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 443 additions and 33 deletions

View File

@ -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.

View File

@ -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<TaskSource>(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<string | null>(null)
const [linearSearchInput, setLinearSearchInput] = useState('')
const [appliedLinearSearch, setAppliedLinearSearch] = useState('')
const [activeLinearPreset, setActiveLinearPreset] = useState<LinearPresetId>('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<ReadonlySet<string>>(() => {
@ -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<HTMLInputElement>): 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 {
<button
key={mode}
type="button"
onClick={() => setGithubMode(mode)}
onClick={() => {
setGithubMode(mode)
setTaskResumeState({ githubMode: mode })
}}
className={cn(
'rounded-md border px-2 py-1 text-xs transition',
active
@ -1925,6 +2009,10 @@ export default function TaskPage(): React.JSX.Element {
setTaskSearchInput(query)
setAppliedTaskSearch(query)
setActiveTaskPreset(option.id)
setTaskResumeState({
githubItemsPreset: option.id,
githubItemsQuery: query
})
setTaskRefreshNonce((current) => current + 1)
}}
onContextMenu={(event) => {
@ -2016,6 +2104,7 @@ export default function TaskPage(): React.JSX.Element {
setTaskSearchInput('')
setAppliedTaskSearch('')
setActiveTaskPreset(null)
setTaskResumeState({ githubItemsPreset: null, githubItemsQuery: '' })
setTaskRefreshNonce((current) => current + 1)
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground transition hover:text-foreground"
@ -2112,6 +2201,7 @@ export default function TaskPage(): React.JSX.Element {
setLinearSearchInput('')
setAppliedLinearSearch('')
setActiveLinearPreset(preset.id)
setTaskResumeState({ linearPreset: preset.id, linearQuery: '' })
setLinearRefreshNonce((n) => n + 1)
}}
className={cn(
@ -2189,7 +2279,10 @@ export default function TaskPage(): React.JSX.Element {
return
}
e.preventDefault()
setAppliedLinearSearch(linearSearchInput.trim())
const trimmed = linearSearchInput.trim()
setLinearSearchInput(trimmed)
setAppliedLinearSearch(trimmed)
setTaskResumeState({ linearQuery: trimmed })
setLinearRefreshNonce((n) => n + 1)
}
}}
@ -2203,6 +2296,7 @@ export default function TaskPage(): React.JSX.Element {
onClick={() => {
setLinearSearchInput('')
setAppliedLinearSearch('')
setTaskResumeState({ linearQuery: '' })
setLinearRefreshNonce((n) => n + 1)
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground transition hover:text-foreground"

View File

@ -1,11 +1,16 @@
import { createStore, type StoreApi } from 'zustand/vanilla'
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getDefaultUIState } from '../../../../shared/constants'
import type { PersistedUIState } from '../../../../shared/types'
import { createUISlice } from './ui'
import { createWorktreeNavHistorySlice } from './worktree-nav-history'
import type { AppState } from '../types'
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function createUIStore(): StoreApi<AppState> {
// Only the UI slice, repo ids, and right sidebar width fallback are needed
// for persisted UI hydration tests. The worktree-nav-history slice is also
@ -109,6 +114,41 @@ describe('createUISlice hydratePersistedUI', () => {
expect(store.getState().hideDefaultBranchWorkspace).toBe(true)
})
it('sanitizes task resume state field-by-field during hydration', () => {
const store = createUIStore()
store.getState().hydratePersistedUI(
makePersistedUI({
taskResumeState: {
githubMode: 'project',
githubItemsPreset: 'invalid',
githubItemsQuery: 42,
linearPreset: 'completed',
linearQuery: 'label:bug'
} as unknown as PersistedUIState['taskResumeState']
})
)
expect(store.getState().taskResumeState).toEqual({
githubMode: 'project',
linearPreset: 'completed',
linearQuery: 'label:bug'
})
})
it('merges and persists partial task resume updates', () => {
const setUI = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('window', { api: { ui: { set: setUI } } })
const store = createUIStore()
store.setState({ taskResumeState: { githubMode: 'project', linearPreset: 'all' } })
store.getState().setTaskResumeState({ githubItemsPreset: 'my-prs' })
const expected = { githubMode: 'project', linearPreset: 'all', githubItemsPreset: 'my-prs' }
expect(store.getState().taskResumeState).toEqual(expected)
expect(setUI).toHaveBeenCalledWith({ taskResumeState: expected })
})
})
describe('createUISlice settings navigation', () => {

View File

@ -8,12 +8,14 @@ import type {
PersistedTrustedOrcaHooks,
PersistedUIState,
StatusBarItem,
TaskResumeState,
TaskViewPresetId,
TuiAgent,
UpdateStatus,
WorktreeCardProperty
} from '../../../../shared/types'
import { PER_REPO_FETCH_LIMIT } from '../../../../shared/work-items'
import { isGitRepoKind } from '../../../../shared/repo-kind'
// Why: mirrors the preset→query mapping used by TaskPage's preset buttons.
// Keeping a local copy here avoids a store ↔ lib circular import while letting
@ -49,6 +51,20 @@ const MAX_LEFT_SIDEBAR_WIDTH = 500
// cap on wide displays. Use a large hard ceiling purely as a safety net for
// corrupted/manually-edited values rather than as a product limit.
const MAX_RIGHT_SIDEBAR_WIDTH = 4000
const VALID_TASK_PRESETS = new Set<TaskViewPresetId>([
'all',
'issues',
'review',
'my-issues',
'my-prs',
'prs'
])
const VALID_LINEAR_PRESETS = new Set<NonNullable<TaskResumeState['linearPreset']>>([
'assigned',
'created',
'all',
'completed'
])
function filterTrustedOrcaHooksToValidRepos(
trust: PersistedTrustedOrcaHooks,
@ -70,6 +86,39 @@ function sanitizePersistedSidebarWidth(width: unknown, fallback: number, maxWidt
return Math.min(maxWidth, Math.max(MIN_SIDEBAR_WIDTH, width))
}
function sanitizeTaskResumeState(value: unknown): TaskResumeState | undefined {
if (!value || typeof value !== 'object') {
return undefined
}
const input = value as Record<string, unknown>
const next: TaskResumeState = {}
if (input.githubMode === 'items' || input.githubMode === 'project') {
next.githubMode = input.githubMode
}
if (input.githubItemsPreset === null) {
next.githubItemsPreset = null
} else if (typeof input.githubItemsPreset === 'string') {
if (VALID_TASK_PRESETS.has(input.githubItemsPreset as TaskViewPresetId)) {
next.githubItemsPreset = input.githubItemsPreset as TaskViewPresetId
}
}
if (typeof input.githubItemsQuery === 'string') {
next.githubItemsQuery = input.githubItemsQuery
}
if (
typeof input.linearPreset === 'string' &&
VALID_LINEAR_PRESETS.has(input.linearPreset as NonNullable<TaskResumeState['linearPreset']>)
) {
next.linearPreset = input.linearPreset as NonNullable<TaskResumeState['linearPreset']>
}
if (typeof input.linearQuery === 'string') {
next.linearQuery = input.linearQuery
}
return Object.keys(next).length > 0 ? next : undefined
}
export type UISlice = {
sidebarOpen: boolean
sidebarWidth: number
@ -100,6 +149,8 @@ export type UISlice = {
prefilledName?: string
taskSource?: 'github' | 'linear'
}
taskResumeState: TaskResumeState | undefined
setTaskResumeState: (updates: Partial<TaskResumeState>) => void
newWorkspaceDraft: {
repoId: string | null
name: string
@ -282,6 +333,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
previousViewBeforeSettings: 'terminal',
setActiveView: (view) => set({ activeView: view }),
taskPageData: {},
taskResumeState: undefined,
newWorkspaceDraft: null,
openTaskPage: (data = {}) => {
// Why: record a Tasks visit in the shared back/forward history so the
@ -303,14 +355,48 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
// be deduped. This removes ~300800ms of perceived latency on initial
// page load.
const state = get()
const targetRepoId =
data.preselectedRepoId ?? state.activeRepoId ?? state.repos.find((r) => r.path)?.id ?? null
const repo = targetRepoId ? state.repos.find((r) => r.id === targetRepoId) : null
if (repo?.path) {
const preset = state.settings?.defaultTaskViewPreset ?? 'all'
state.prefetchWorkItems(repo.id, repo.path, PER_REPO_FETCH_LIMIT, presetToQuery(preset))
const resolvedSource = data.taskSource ?? state.settings?.defaultTaskSource ?? 'github'
const resolvedMode = state.taskResumeState?.githubMode ?? 'items'
if (resolvedSource === 'github' && resolvedMode === 'items') {
const eligibleRepos = state.repos.filter((repo) => isGitRepoKind(repo) && repo.path)
const selectedRepos = (() => {
const preferred = data.preselectedRepoId
if (preferred) {
const repo = eligibleRepos.find((r) => r.id === preferred)
return repo ? [repo] : []
}
const persisted = state.settings?.defaultRepoSelection
if (Array.isArray(persisted)) {
const selected = eligibleRepos.filter((repo) => persisted.includes(repo.id))
if (selected.length > 0) {
return selected
}
}
return eligibleRepos
})()
const resume = state.taskResumeState
const defaultPreset = state.settings?.defaultTaskViewPreset ?? 'all'
// Why: must match the exact query TaskPage's resume effect mounts with,
// otherwise the warm cache key (e.g. 'is:open') misses the page's actual
// fetch key (e.g. '') and the prefetch is wasted. When the user has an
// explicit cleared custom search (preset === null), preserve the empty
// query so both sides agree.
const query =
resume?.githubItemsPreset === null
? (resume.githubItemsQuery ?? '').trim()
: presetToQuery(resume?.githubItemsPreset ?? defaultPreset)
for (const repo of selectedRepos) {
state.prefetchWorkItems(repo.id, repo.path, PER_REPO_FETCH_LIMIT, query)
}
}
},
setTaskResumeState: (updates) =>
set((s) => {
const next = { ...s.taskResumeState, ...updates }
window.api.ui.set({ taskResumeState: next }).catch(console.error)
return { taskResumeState: next }
}),
closeTaskPage: () =>
set((state) => {
// Why: Esc-close from Tasks must rewind the history index if we're
@ -590,6 +676,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
updateReassuranceSeen: ui.updateReassuranceSeen ?? false,
browserDefaultUrl: ui.browserDefaultUrl ?? null,
browserDefaultSearchEngine: ui.browserDefaultSearchEngine ?? null,
taskResumeState: sanitizeTaskResumeState(ui.taskResumeState),
trustedOrcaHooks: filterTrustedOrcaHooksToValidRepos(
ui.trustedOrcaHooks ?? {},
validRepoIds

View File

@ -1315,6 +1315,15 @@ export type StatusBarItem =
| 'ssh'
| 'sessions'
| 'memory'
export type TaskResumeState = {
githubMode?: 'items' | 'project'
githubItemsPreset?: TaskViewPresetId | null
githubItemsQuery?: string
linearPreset?: 'assigned' | 'created' | 'all' | 'completed'
linearQuery?: string
}
export type PersistedUIState = {
lastActiveRepoId: string | null
lastActiveWorktreeId: string | null
@ -1403,6 +1412,10 @@ export type PersistedUIState = {
* this field is the metadata index so custom sidekicks ride the existing
* PersistedUIState save pipeline. */
customSidekicks?: CustomSidekick[]
/** Page-position state for Tasks. Source/repo/team/project selections keep
* using their existing settings paths; this only restores transient tabs
* and applied searches. */
taskResumeState?: TaskResumeState
}
/** Metadata for a user-uploaded sidekick image. `id` is the stable identifier;