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:
parent
14e879a825
commit
5ce9c3b481
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 ~300–800ms 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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue