Scope Linear optimistic task patches

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-12 21:14:48 -07:00
co-authored by Orca
parent 29ebf4b3bc
commit 836ada1df0
5 changed files with 142 additions and 27 deletions
@@ -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.
@@ -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 (
@@ -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
]
)
+76 -1
View File
@@ -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', () => {
+28 -9
View File
@@ -328,7 +328,8 @@ function largestCachedCollectionBelowLimit<T>(
function patchLinearIssueCollectionCache(
cache: Record<string, CacheEntry<LinearCollectionResult<LinearIssue>>>,
issueId: string,
patch: Partial<LinearIssue>
patch: Partial<LinearIssue>,
canPatchCacheKey: (key: string) => boolean
): {
cache: Record<string, CacheEntry<LinearCollectionResult<LinearIssue>>>
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<LinearCollectionResult<LinearProjectSummary>>
patchLinearIssue: (issueId: string, patch: Partial<LinearIssue>) => void
patchLinearIssue: (
issueId: string,
patch: Partial<LinearIssue>,
options?: LinearPatchOptions
) => void
}
export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (set, get) => ({
@@ -2046,13 +2052,19 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (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<AppState, [], [], LinearSlice> = (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<AppState, [], [], LinearSlice> = (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<AppState, [], [], LinearSlice> = (s
const nextProjectIssueCache = patchLinearIssueCollectionCache(
s.linearProjectIssueCache,
issueId,
patch
patch,
canPatchCacheKey
)
if (nextProjectIssueCache.changed) {
changed = true
@@ -2098,7 +2116,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
const nextCustomViewIssueCache = patchLinearIssueCollectionCache(
s.linearCustomViewIssueCache,
issueId,
patch
patch,
canPatchCacheKey
)
if (nextCustomViewIssueCache.changed) {
changed = true