diff --git a/notes/host-context-implementation-checklist.md b/notes/host-context-implementation-checklist.md index 724521a15b4..bf0c245a607 100644 --- a/notes/host-context-implementation-checklist.md +++ b/notes/host-context-implementation-checklist.md @@ -222,6 +222,7 @@ Done means: reviewers and future agents can understand the vision, implementatio - [x] 2026-06-12: Scoped Jira optimistic detail patches to the selected `TaskSourceContext` so a same-key issue cached from another Jira host/site is not visually mutated; verified with `jira.test.ts`, focused oxlint, and full `pnpm run typecheck`. - [x] 2026-06-12: Updated Composer GitHub PR and GitLab MR base-resolution to run against the selected workspace run repo instead of the provider item's source repo, preserving task source identity separately from run-host git commands; verified with a host-context boundary test, focused oxlint, and full `pnpm run typecheck`. - [x] 2026-06-12: Added Jira provider-depth interaction recording when starting workspace creation from a Jira issue, matching GitHub/GitLab/Linear task-provider behavior; verified with the feature-interaction writer-boundary test and focused oxlint. +- [x] 2026-06-12: Scoped Linear optimistic issue patches to the selected `TaskSourceContext` so same-id issues cached from another Linear host/workspace are not visually mutated; threaded source context through Linear drawer/project edits and verified with `linear.test.ts`, focused oxlint, and full `pnpm run typecheck`. - [x] 2026-06-12: Threaded GitHub Tasks row-level mutations through `TaskSourceContext` for issue state, assignees, reviewer requests/removals, PR merge/auto-merge, and new issue creation/follow-up reads; verified with focused GitHub/TaskPage tests, focused oxlint, `pnpm run typecheck`, and `git diff --check`. - [x] 2026-06-12: Added provider account-scope diagnostics to GitHub/GitLab CLI integration cards so code-host credential checks show whether they belong to the local desktop client or selected remote server, matching the existing Linear/Jira ownership copy; verified with focused integration-card scope tests and oxlint. - [x] 2026-06-12: Extended task-detail navigation state to GitLab and fixed Linear replay so saved `TaskSourceContext` is restored through back/forward and direct detail reopens; GitLab detail history keys now include source scope so identical MRs from different hosts stay distinct. Verified with focused UI/history/static-boundary tests and oxlint. diff --git a/src/renderer/src/components/LinearIssueWorkspace.tsx b/src/renderer/src/components/LinearIssueWorkspace.tsx index fe8fa29b8d1..d3090782380 100644 --- a/src/renderer/src/components/LinearIssueWorkspace.tsx +++ b/src/renderer/src/components/LinearIssueWorkspace.tsx @@ -383,7 +383,7 @@ function LinearIssueSidebarProjectCard({ ) if (result.ok) { onProjectChanged(project) - patchLinearIssue(issue.id, { project }) + patchLinearIssue(issue.id, { project }, { sourceContext }) toast.success( translate('auto.components.LinearIssueWorkspace.f9d4ef9807', 'Project updated') ) @@ -404,7 +404,14 @@ function LinearIssueSidebarProjectCard({ setSavingProjectId(null) } }, - [issue.id, issue.workspaceId, onProjectChanged, patchLinearIssue, providerSettings] + [ + issue.id, + issue.workspaceId, + onProjectChanged, + patchLinearIssue, + providerSettings, + sourceContext + ] ) return ( diff --git a/src/renderer/src/components/LinearItemDrawer.tsx b/src/renderer/src/components/LinearItemDrawer.tsx index d11758e2ac5..d123808f227 100644 --- a/src/renderer/src/components/LinearItemDrawer.tsx +++ b/src/renderer/src/components/LinearItemDrawer.tsx @@ -192,11 +192,11 @@ export function LinearIssueEditSection({ mutate: () => linearUpdateIssue(providerSettings, issue.id, { stateId }, issue.workspaceId), onOptimistic: () => { onEditStateChange({ state: stateValue }) - patchLinearIssue(issue.id, { state: stateValue }) + patchLinearIssue(issue.id, { state: stateValue }, { sourceContext }) }, onRevert: () => { onEditStateChange({ state: prevState }) - patchLinearIssue(issue.id, { state: prevState }) + patchLinearIssue(issue.id, { state: prevState }, { sourceContext }) }, onSuccess: () => { useAppStore.getState().recordFeatureInteraction('linear-tasks') @@ -212,7 +212,8 @@ export function LinearIssueEditSection({ states.data, patchLinearIssue, run, - onEditStateChange + onEditStateChange, + sourceContext ] ) @@ -225,11 +226,11 @@ export function LinearIssueEditSection({ linearUpdateIssue(providerSettings, issue.id, { priority }, issue.workspaceId), onOptimistic: () => { onEditStateChange({ priority }) - patchLinearIssue(issue.id, { priority }) + patchLinearIssue(issue.id, { priority }, { sourceContext }) }, onRevert: () => { onEditStateChange({ priority: prevPriority }) - patchLinearIssue(issue.id, { priority: prevPriority }) + patchLinearIssue(issue.id, { priority: prevPriority }, { sourceContext }) }, onSuccess: () => { useAppStore.getState().recordFeatureInteraction('linear-tasks') @@ -244,7 +245,8 @@ export function LinearIssueEditSection({ providerSettings, patchLinearIssue, run, - onEditStateChange + onEditStateChange, + sourceContext ] ) @@ -256,12 +258,12 @@ export function LinearIssueEditSection({ linearUpdateIssue(providerSettings, issue.id, { estimate }, issue.workspaceId), onOptimistic: () => { onEditStateChange({ estimate }) - patchLinearIssue(issue.id, { estimate }) + patchLinearIssue(issue.id, { estimate }, { sourceContext }) setEstimatePopoverOpen(false) }, onRevert: () => { onEditStateChange({ estimate: prevEstimate }) - patchLinearIssue(issue.id, { estimate: prevEstimate }) + patchLinearIssue(issue.id, { estimate: prevEstimate }, { sourceContext }) }, onSuccess: () => { useAppStore.getState().recordFeatureInteraction('linear-tasks') @@ -276,7 +278,8 @@ export function LinearIssueEditSection({ providerSettings, patchLinearIssue, run, - onEditStateChange + onEditStateChange, + sourceContext ] ) @@ -314,11 +317,11 @@ export function LinearIssueEditSection({ linearUpdateIssue(providerSettings, issue.id, { assigneeId }, issue.workspaceId), onOptimistic: () => { onEditStateChange({ assignee: newAssignee }) - patchLinearIssue(issue.id, { assignee: newAssignee }) + patchLinearIssue(issue.id, { assignee: newAssignee }, { sourceContext }) }, onRevert: () => { onEditStateChange({ assignee: prevAssignee }) - patchLinearIssue(issue.id, { assignee: prevAssignee }) + patchLinearIssue(issue.id, { assignee: prevAssignee }, { sourceContext }) }, onSuccess: () => { useAppStore.getState().recordFeatureInteraction('linear-tasks') @@ -334,7 +337,8 @@ export function LinearIssueEditSection({ members.data, patchLinearIssue, run, - onEditStateChange + onEditStateChange, + sourceContext ] ) @@ -360,11 +364,19 @@ export function LinearIssueEditSection({ ), onOptimistic: () => { onEditStateChange({ labelIds: newLabelIds, labels: newLabels }) - patchLinearIssue(issue.id, { labelIds: newLabelIds, labels: newLabels }) + patchLinearIssue( + issue.id, + { labelIds: newLabelIds, labels: newLabels }, + { sourceContext } + ) }, onRevert: () => { onEditStateChange({ labelIds: prevLabelIds, labels: prevLabels }) - patchLinearIssue(issue.id, { labelIds: prevLabelIds, labels: prevLabels }) + patchLinearIssue( + issue.id, + { labelIds: prevLabelIds, labels: prevLabels }, + { sourceContext } + ) }, onSuccess: () => { useAppStore.getState().recordFeatureInteraction('linear-tasks') @@ -381,7 +393,8 @@ export function LinearIssueEditSection({ labels.data, patchLinearIssue, run, - onEditStateChange + onEditStateChange, + sourceContext ] ) diff --git a/src/renderer/src/store/slices/linear.test.ts b/src/renderer/src/store/slices/linear.test.ts index b985c0fd6f9..ab0924755cd 100644 --- a/src/renderer/src/store/slices/linear.test.ts +++ b/src/renderer/src/store/slices/linear.test.ts @@ -12,7 +12,10 @@ import type { LinearTeam, LinearViewer } from '../../../../shared/types' -import type { TaskSourceContext } from '../../../../shared/task-source-context' +import { + getTaskSourceCacheScope, + type TaskSourceContext +} from '../../../../shared/task-source-context' import { credentialDecryptionMessage } from '../../../../shared/integration-credential-errors' import { createLinearSlice } from './linear' @@ -836,6 +839,78 @@ describe('createLinearSlice caching', () => { .data?.items[0]?.title ).toBe('Updated') }) + + it('scopes optimistic issue patches to the selected Linear source context', () => { + const store = createTestStore() + const localSource = linearSourceContext('local-runtime') + const remoteSource = linearSourceContext('remote-runtime') + const localScope = getTaskSourceCacheScope(localSource) + const remoteScope = getTaskSourceCacheScope(remoteSource) + + store.setState({ + linearIssueCache: { + [`${localScope}::workspace-1::issue-id`]: { + data: { ...issue('issue-id'), title: 'Local title' }, + fetchedAt: Date.now() + }, + [`${remoteScope}::workspace-1::issue-id`]: { + data: { ...issue('issue-id'), title: 'Remote title' }, + fetchedAt: Date.now() + } + }, + linearSearchCache: { + [`${localScope}::workspace-1::search::query::20`]: { + data: [{ ...issue('issue-id'), title: 'Local title' }], + fetchedAt: Date.now() + }, + [`${remoteScope}::workspace-1::search::query::20`]: { + data: [{ ...issue('issue-id'), title: 'Remote title' }], + fetchedAt: Date.now() + } + }, + linearListCache: { + [`${localScope}::workspace-1::list::all::36`]: { + data: { items: [{ ...issue('issue-id'), title: 'Local title' }] }, + fetchedAt: Date.now() + }, + [`${remoteScope}::workspace-1::list::all::36`]: { + data: { items: [{ ...issue('issue-id'), title: 'Remote title' }] }, + fetchedAt: Date.now() + } + } + }) + + store.getState().patchLinearIssue( + 'issue-id', + { title: 'Patched local title' }, + { + sourceContext: localSource + } + ) + + expect( + store.getState().linearIssueCache[`${localScope}::workspace-1::issue-id`]?.data?.title + ).toBe('Patched local title') + expect( + store.getState().linearIssueCache[`${remoteScope}::workspace-1::issue-id`]?.data?.title + ).toBe('Remote title') + expect( + store.getState().linearSearchCache[`${localScope}::workspace-1::search::query::20`]?.data?.[0] + ?.title + ).toBe('Patched local title') + expect( + store.getState().linearSearchCache[`${remoteScope}::workspace-1::search::query::20`] + ?.data?.[0]?.title + ).toBe('Remote title') + expect( + store.getState().linearListCache[`${localScope}::workspace-1::list::all::36`]?.data?.items[0] + ?.title + ).toBe('Patched local title') + expect( + store.getState().linearListCache[`${remoteScope}::workspace-1::list::all::36`]?.data?.items[0] + ?.title + ).toBe('Remote title') + }) }) describe('createLinearSlice', () => { diff --git a/src/renderer/src/store/slices/linear.ts b/src/renderer/src/store/slices/linear.ts index 9d08a66c8b3..2ae65f7f468 100644 --- a/src/renderer/src/store/slices/linear.ts +++ b/src/renderer/src/store/slices/linear.ts @@ -328,7 +328,8 @@ function largestCachedCollectionBelowLimit( function patchLinearIssueCollectionCache( cache: Record>>, issueId: string, - patch: Partial + patch: Partial, + canPatchCacheKey: (key: string) => boolean ): { cache: Record>> changed: boolean @@ -336,7 +337,7 @@ function patchLinearIssueCollectionCache( let changed = false const nextCache = { ...cache } for (const [key, entry] of Object.entries(nextCache)) { - if (!entry?.data) { + if (!canPatchCacheKey(key) || !entry?.data) { continue } const idx = entry.data.items.findIndex((item) => item.id === issueId) @@ -359,6 +360,7 @@ type LinearIssueReadArgs = | { kind: 'list'; filter?: 'assigned' | 'created' | 'all' | 'completed'; limit?: number } type LinearFetchOptions = { force?: boolean; sourceContext?: TaskSourceContext | null } +type LinearPatchOptions = { sourceContext?: TaskSourceContext | null } type LinearReadScope = { settings: AppState['settings'] | TaskSourceContext | null @@ -533,7 +535,11 @@ export type LinearSlice = { limit?: number, options?: LinearFetchOptions ) => Promise> - patchLinearIssue: (issueId: string, patch: Partial) => void + patchLinearIssue: ( + issueId: string, + patch: Partial, + options?: LinearPatchOptions + ) => void } export const createLinearSlice: StateCreator = (set, get) => ({ @@ -2046,13 +2052,19 @@ export const createLinearSlice: StateCreator = (s return promise }, - patchLinearIssue: (issueId, patch) => { + patchLinearIssue: (issueId, patch, options) => { + const sourceScope = + options?.sourceContext?.provider === 'linear' + ? getTaskSourceCacheScope(options.sourceContext) + : null + const canPatchCacheKey = (key: string): boolean => + sourceScope === null || key.startsWith(`${sourceScope}::`) set((s) => { let changed = false const nextIssueCache = { ...s.linearIssueCache } for (const [key, issueEntry] of Object.entries(nextIssueCache)) { - if (issueEntry?.data?.id !== issueId) { + if (!canPatchCacheKey(key) || issueEntry?.data?.id !== issueId) { continue } // Why: set fetchedAt to 0 so the next fetchLinearIssue call @@ -2068,7 +2080,7 @@ export const createLinearSlice: StateCreator = (s const nextSearchCache = { ...s.linearSearchCache } for (const key of Object.keys(nextSearchCache)) { const entry = nextSearchCache[key] - if (!entry?.data) { + if (!canPatchCacheKey(key) || !entry?.data) { continue } const idx = entry.data.findIndex((item) => item.id === issueId) @@ -2081,7 +2093,12 @@ export const createLinearSlice: StateCreator = (s changed = true } - const nextListCache = patchLinearIssueCollectionCache(s.linearListCache, issueId, patch) + const nextListCache = patchLinearIssueCollectionCache( + s.linearListCache, + issueId, + patch, + canPatchCacheKey + ) if (nextListCache.changed) { changed = true } @@ -2089,7 +2106,8 @@ export const createLinearSlice: StateCreator = (s const nextProjectIssueCache = patchLinearIssueCollectionCache( s.linearProjectIssueCache, issueId, - patch + patch, + canPatchCacheKey ) if (nextProjectIssueCache.changed) { changed = true @@ -2098,7 +2116,8 @@ export const createLinearSlice: StateCreator = (s const nextCustomViewIssueCache = patchLinearIssueCollectionCache( s.linearCustomViewIssueCache, issueId, - patch + patch, + canPatchCacheKey ) if (nextCustomViewIssueCache.changed) { changed = true