diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index 8aaa7581..1c7c4ce9 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -5,7 +5,7 @@ import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider"; import { queryKeys } from "../lib/queryKeys"; describe("LiveUpdatesProvider issue invalidation", () => { - it("refreshes touched inbox queries for issue activity", () => { + it("refreshes touched inbox queries and only the changed issue data for issue updates", () => { const invalidations: unknown[] = []; const queryClient = { invalidateQueries: (input: unknown) => { @@ -20,6 +20,7 @@ describe("LiveUpdatesProvider issue invalidation", () => { { entityType: "issue", entityId: "issue-1", + action: "issue.updated", details: null, }, ); @@ -33,6 +34,58 @@ describe("LiveUpdatesProvider issue invalidation", () => { expect(invalidations).toContainEqual({ queryKey: queryKeys.issues.listUnreadTouchedByMe("company-1"), }); + expect(invalidations).toContainEqual({ + queryKey: queryKeys.issues.detail("issue-1"), + }); + expect(invalidations).toContainEqual({ + queryKey: queryKeys.issues.activity("issue-1"), + }); + expect(invalidations).not.toContainEqual({ + queryKey: queryKeys.issues.comments("issue-1"), + }); + expect(invalidations).not.toContainEqual({ + queryKey: queryKeys.issues.runs("issue-1"), + }); + expect(invalidations).not.toContainEqual({ + queryKey: queryKeys.issues.documents("issue-1"), + }); + expect(invalidations).not.toContainEqual({ + queryKey: queryKeys.issues.attachments("issue-1"), + }); + expect(invalidations).not.toContainEqual({ + queryKey: queryKeys.issues.approvals("issue-1"), + }); + expect(invalidations).not.toContainEqual({ + queryKey: queryKeys.issues.liveRuns("issue-1"), + }); + expect(invalidations).not.toContainEqual({ + queryKey: queryKeys.issues.activeRun("issue-1"), + }); + }); + + it("still refreshes comments when a comment activity event arrives", () => { + const invalidations: unknown[] = []; + const queryClient = { + invalidateQueries: (input: unknown) => { + invalidations.push(input); + }, + getQueryData: () => undefined, + }; + + __liveUpdatesTestUtils.invalidateActivityQueries( + queryClient as never, + "company-1", + { + entityType: "issue", + entityId: "issue-1", + action: "issue.comment_added", + details: null, + }, + ); + + expect(invalidations).toContainEqual({ + queryKey: queryKeys.issues.comments("issue-1"), + }); }); }); diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 71e6fbc8..4b427ce2 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -487,6 +487,7 @@ function invalidateActivityQueries( const entityType = readString(payload.entityType); const entityId = readString(payload.entityId); + const action = readString(payload.action); if (entityType === "issue") { queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) }); @@ -498,14 +499,10 @@ function invalidateActivityQueries( const issueRefs = resolveIssueQueryRefs(queryClient, companyId, entityId, details); for (const ref of issueRefs) { queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(ref) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(ref) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(ref) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(ref) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(ref) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(ref) }); + if (action === "issue.comment_added") { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref) }); + } } } return; diff --git a/ui/src/lib/optimistic-issue-comments.test.ts b/ui/src/lib/optimistic-issue-comments.test.ts index 1620d329..12659f61 100644 --- a/ui/src/lib/optimistic-issue-comments.test.ts +++ b/ui/src/lib/optimistic-issue-comments.test.ts @@ -1,8 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Issue } from "@paperclipai/shared"; import { + applyOptimisticIssueFieldUpdate, + applyOptimisticIssueFieldUpdateToCollection, applyOptimisticIssueCommentUpdate, createOptimisticIssueComment, isQueuedIssueComment, + matchesIssueRef, mergeIssueComments, upsertIssueComment, } from "./optimistic-issue-comments"; @@ -177,6 +181,267 @@ describe("optimistic issue comments", () => { expect(next?.assigneeUserId).toBe("board-2"); }); + it("applies optimistic field updates for issue property edits", () => { + const next = applyOptimisticIssueFieldUpdate( + { + id: "issue-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: "workspace-1", + goalId: null, + parentId: null, + title: "Fix property pane", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: "board-1", + issueNumber: 1, + identifier: "PAP-1", + originKind: "manual", + originId: null, + originRunId: null, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: "exec-1", + executionWorkspacePreference: "shared_workspace", + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + labelIds: ["label-1", "label-2"], + labels: [ + { + id: "label-1", + companyId: "company-1", + name: "One", + color: "#111111", + createdAt: new Date("2026-03-28T14:00:00.000Z"), + updatedAt: new Date("2026-03-28T14:00:00.000Z"), + }, + { + id: "label-2", + companyId: "company-1", + name: "Two", + color: "#222222", + createdAt: new Date("2026-03-28T14:00:00.000Z"), + updatedAt: new Date("2026-03-28T14:00:00.000Z"), + }, + ], + blockedBy: [ + { + id: "issue-2", + identifier: "PAP-2", + title: "First blocker", + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + }, + { + id: "issue-3", + identifier: "PAP-3", + title: "Second blocker", + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + }, + ], + blocks: [], + project: { + id: "project-1", + companyId: "company-1", + urlKey: "project-one", + goalId: null, + goalIds: [], + goals: [], + name: "Project one", + description: null, + status: "in_progress", + leadAgentId: null, + targetDate: null, + color: null, + env: null, + pauseReason: null, + pausedAt: null, + executionWorkspacePolicy: null, + codebase: { + workspaceId: null, + repoUrl: null, + repoRef: null, + defaultRef: null, + repoName: null, + localFolder: null, + managedFolder: "/tmp/paperclip", + effectiveLocalFolder: "/tmp/paperclip", + origin: "local_folder", + }, + workspaces: [], + primaryWorkspace: null, + archivedAt: null, + createdAt: new Date("2026-03-28T14:00:00.000Z"), + updatedAt: new Date("2026-03-28T14:00:00.000Z"), + }, + currentExecutionWorkspace: { + id: "exec-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: null, + sourceIssueId: "issue-1", + mode: "shared_workspace", + strategyType: "project_primary", + branchName: null, + status: "active", + name: "Execution workspace", + cwd: "/tmp/paperclip", + repoUrl: null, + baseRef: null, + providerType: "local_fs", + providerRef: null, + derivedFromExecutionWorkspaceId: null, + lastUsedAt: new Date("2026-03-28T14:00:00.000Z"), + cleanupEligibleAt: null, + cleanupReason: null, + config: null, + metadata: null, + createdAt: new Date("2026-03-28T14:00:00.000Z"), + updatedAt: new Date("2026-03-28T14:00:00.000Z"), + openedAt: new Date("2026-03-28T14:00:00.000Z"), + closedAt: null, + }, + createdAt: new Date("2026-03-28T14:00:00.000Z"), + updatedAt: new Date("2026-03-28T14:00:00.000Z"), + }, + { + status: "in_review", + assigneeAgentId: null, + assigneeUserId: "board-2", + labelIds: ["label-2"], + blockedByIssueIds: ["issue-3"], + projectId: "project-2", + executionWorkspaceId: "exec-2", + }, + ); + + expect(next?.status).toBe("in_review"); + expect(next?.assigneeAgentId).toBeNull(); + expect(next?.assigneeUserId).toBe("board-2"); + expect(next?.labelIds).toEqual(["label-2"]); + expect(next?.labels?.map((label) => label.id)).toEqual(["label-2"]); + expect(next?.blockedBy?.map((relation) => relation.id)).toEqual(["issue-3"]); + expect(next?.projectId).toBe("project-2"); + expect(next?.project).toBeNull(); + expect(next?.executionWorkspaceId).toBe("exec-2"); + expect(next?.currentExecutionWorkspace).toBeNull(); + }); + + it("matches issues by either uuid or identifier reference", () => { + expect(matchesIssueRef({ id: "issue-1", identifier: "PAP-1" } as const, ["issue-1"])).toBe(true); + expect(matchesIssueRef({ id: "issue-1", identifier: "PAP-1" } as const, ["PAP-1"])).toBe(true); + expect(matchesIssueRef({ id: "issue-1", identifier: "PAP-1" } as const, ["issue-2", "PAP-2"])).toBe(false); + }); + + it("applies optimistic field updates across cached issue collections", () => { + const issues: Issue[] = [ + { + id: "issue-1", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Fix property pane", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: "board-1", + issueNumber: 1, + identifier: "PAP-1", + originKind: "manual", + originId: null, + originRunId: null, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + labelIds: [], + labels: [], + blockedBy: [], + blocks: [], + createdAt: new Date("2026-03-28T14:00:00.000Z"), + updatedAt: new Date("2026-03-28T14:00:00.000Z"), + }, + { + id: "issue-2", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Leave me alone", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: "agent-2", + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: "board-1", + issueNumber: 2, + identifier: "PAP-2", + originKind: "manual", + originId: null, + originRunId: null, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + labelIds: [], + labels: [], + blockedBy: [], + blocks: [], + createdAt: new Date("2026-03-28T14:00:00.000Z"), + updatedAt: new Date("2026-03-28T14:00:00.000Z"), + }, + ]; + + const next = applyOptimisticIssueFieldUpdateToCollection(issues, ["PAP-1"], { assigneeAgentId: "agent-9" }); + + expect(next?.[0]?.assigneeAgentId).toBe("agent-9"); + expect(next?.[1]?.assigneeAgentId).toBe("agent-2"); + }); + it("treats comments without a run id as queued when they arrive during an active run", () => { expect( isQueuedIssueComment({ diff --git a/ui/src/lib/optimistic-issue-comments.ts b/ui/src/lib/optimistic-issue-comments.ts index 1ac6812c..be35ebb8 100644 --- a/ui/src/lib/optimistic-issue-comments.ts +++ b/ui/src/lib/optimistic-issue-comments.ts @@ -128,3 +128,85 @@ export function applyOptimisticIssueCommentUpdate( return nextIssue; } + +export function applyOptimisticIssueFieldUpdate( + issue: Issue | undefined, + data: Record, +) { + if (!issue) return issue; + + const nextIssue: Issue = { + ...issue, + updatedAt: new Date(), + }; + const hasOwn = (key: string) => Object.prototype.hasOwnProperty.call(data, key); + const assign = (key: K) => { + if (hasOwn(key)) { + nextIssue[key] = data[key] as Issue[K]; + } + }; + + assign("status"); + assign("priority"); + assign("assigneeAgentId"); + assign("assigneeUserId"); + assign("projectId"); + assign("projectWorkspaceId"); + assign("executionWorkspaceId"); + assign("executionWorkspacePreference"); + assign("executionWorkspaceSettings"); + assign("hiddenAt"); + + if (hasOwn("labelIds") && Array.isArray(data.labelIds)) { + const nextLabelIds = data.labelIds.filter((value): value is string => typeof value === "string"); + nextIssue.labelIds = nextLabelIds; + if (issue.labels) { + nextIssue.labels = issue.labels.filter((label) => nextLabelIds.includes(label.id)); + } + } + + if (hasOwn("blockedByIssueIds") && Array.isArray(data.blockedByIssueIds) && issue.blockedBy) { + const nextBlockedByIds = new Set( + data.blockedByIssueIds.filter((value): value is string => typeof value === "string"), + ); + nextIssue.blockedBy = issue.blockedBy.filter((relation) => nextBlockedByIds.has(relation.id)); + } + + if (hasOwn("projectId")) { + nextIssue.project = issue.project?.id === nextIssue.projectId ? issue.project : null; + } + + if (hasOwn("executionWorkspaceId")) { + nextIssue.currentExecutionWorkspace = + issue.currentExecutionWorkspace?.id === nextIssue.executionWorkspaceId + ? issue.currentExecutionWorkspace + : null; + } + + return nextIssue; +} + +export function matchesIssueRef( + issue: Pick, + refs: Iterable, +) { + const refSet = refs instanceof Set ? refs : new Set(refs); + return refSet.has(issue.id) || (!!issue.identifier && refSet.has(issue.identifier)); +} + +export function applyOptimisticIssueFieldUpdateToCollection( + issues: Issue[] | undefined, + refs: Iterable, + data: Record, +) { + if (!issues) return issues; + + let changed = false; + const nextIssues = issues.map((issue) => { + if (!matchesIssueRef(issue, refs)) return issue; + changed = true; + return applyOptimisticIssueFieldUpdate(issue, data) ?? issue; + }); + + return changed ? nextIssues : issues; +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 16346bb5..60b0a9db 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -28,9 +28,12 @@ import { } from "../lib/issueDetailBreadcrumb"; import { hasBlockingShortcutDialog, resolveInboxQuickArchiveKeyAction } from "../lib/keyboardShortcuts"; import { + applyOptimisticIssueFieldUpdate, + applyOptimisticIssueFieldUpdateToCollection, applyOptimisticIssueCommentUpdate, createOptimisticIssueComment, isQueuedIssueComment, + matchesIssueRef, mergeIssueComments, upsertIssueComment, type IssueCommentReassignment, @@ -687,6 +690,42 @@ export function IssueDetail() { } }; + const invalidateIssueCollections = () => { + if (selectedCompanyId) { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(selectedCompanyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) }); + } + }; + + const applyOptimisticIssueCacheUpdate = useCallback((refs: Iterable, data: Record) => { + queryClient.setQueriesData( + { queryKey: ["issues", "detail"] }, + (cached) => (cached && matchesIssueRef(cached, refs) ? applyOptimisticIssueFieldUpdate(cached, data) : cached), + ); + + if (!selectedCompanyId) return; + queryClient.setQueryData( + queryKeys.issues.list(selectedCompanyId), + (cached) => applyOptimisticIssueFieldUpdateToCollection(cached, refs, data), + ); + }, [queryClient, selectedCompanyId]); + + const mergeIssueResponseIntoCaches = useCallback((refs: Iterable, nextIssue: Issue) => { + queryClient.setQueriesData( + { queryKey: ["issues", "detail"] }, + (cached) => (cached && matchesIssueRef(cached, refs) ? { ...cached, ...nextIssue } : cached), + ); + + if (!selectedCompanyId) return; + queryClient.setQueryData( + queryKeys.issues.list(selectedCompanyId), + (cached) => cached?.map((item) => (matchesIssueRef(item, refs) ? { ...item, ...nextIssue } : item)), + ); + }, [queryClient, selectedCompanyId]); + const markIssueRead = useMutation({ mutationFn: (id: string) => issuesApi.markRead(id), onSuccess: () => { @@ -701,8 +740,53 @@ export function IssueDetail() { const updateIssue = useMutation({ mutationFn: (data: Record) => issuesApi.update(issueId!, data), - onSuccess: () => { - invalidateIssue(); + onMutate: async (data) => { + await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) }); + if (selectedCompanyId) { + await queryClient.cancelQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); + } + + const previousIssue = queryClient.getQueryData(queryKeys.issues.detail(issueId!)); + const issueRefs = new Set([issueId!]); + if (previousIssue?.id) issueRefs.add(previousIssue.id); + if (previousIssue?.identifier) issueRefs.add(previousIssue.identifier); + + const previousDetailQueries = queryClient + .getQueriesData({ queryKey: ["issues", "detail"] }) + .filter(([, cachedIssue]) => cachedIssue && matchesIssueRef(cachedIssue, issueRefs)); + const previousList = selectedCompanyId + ? queryClient.getQueryData(queryKeys.issues.list(selectedCompanyId)) + : undefined; + + applyOptimisticIssueCacheUpdate(issueRefs, data); + + return { previousDetailQueries, previousList, selectedCompanyId }; + }, + onSuccess: ({ comment: _comment, ...nextIssue }) => { + const issueRefs = new Set([issueId!, nextIssue.id]); + if (nextIssue.identifier) issueRefs.add(nextIssue.identifier); + mergeIssueResponseIntoCaches(issueRefs, nextIssue); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }); + invalidateIssueCollections(); + }, + onError: (err, _variables, context) => { + for (const [queryKey, previousIssue] of context?.previousDetailQueries ?? []) { + queryClient.setQueryData(queryKey, previousIssue); + } + if (context?.selectedCompanyId) { + queryClient.setQueryData(queryKeys.issues.list(context.selectedCompanyId), context.previousList); + } + pushToast({ + title: "Issue update failed", + body: err instanceof Error ? err.message : "Unable to save issue changes", + tone: "error", + }); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }); + if (selectedCompanyId) { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); + } }, }); const handleIssuePropertiesUpdate = useCallback((data: Record) => {