Files
orca/src/shared/rpc-contract/worktree-params.ts
T
NeilandKelvin Amoaba 5470ff907c fix(mobile): stop review notes from disappearing or coming back after deletion (#31699)
* fix(mobile): stop review notes disappearing or coming back after deletion

The phone sent its whole notes list on every add or delete, and the host
stored it as is. A note added on desktop after the phone loaded was wiped
by the phone's next edit, two quick taps could overwrite each other, and a
failed save rolled back to an old snapshot.

worktree.set now takes an optional diffCommentsBase, like linkedItemsBase:
the host applies only the writer's own adds, edits and removals to the
stored notes. The phone sends the list it started from, shows the host's
merged list from the reply, and undoes only its own change on failure.
Older hosts drop the field and replace the list as before; older phones
send no base and get the same.

Co-authored-by: Neil <neil@stably.ai>

* fix(mobile): keep loaded review notes when an early save fails

The first load was discarded once any save started, so a save that failed
before it landed left the host's notes hidden. The load now applies the
phone's changes made during the read onto the host's list instead.

* fix(mobile): send review-note requests one at a time

Two in-flight saves could reach the host in either order, so adopting the
later reply hid a note the earlier save had stored. Session notes now send
one request at a time and reconcile every reply with the phone's changes
made since it was sent, replacing the sequence counters.

* fix(mobile): drop review-note saves queued behind a failed one

A save queued behind one that failed still sent the list built on the
failed change, and an older host stores that list whole. Those saves now
undo their own change instead of sending. Also renames the shared base
type so it no longer shares a name with the renderer's mutation record.

* fix(mobile): show review notes as the host's list plus pending saves

A queued reply was reconciled against the list captured at tap time, so a
note desktop deleted meanwhile came back on the phone. The phone now keeps
the host's last list and its unanswered saves per workspace and shows the
two combined; a reply for another workspace is ignored. The review screen
also shows the host's list after a save, so an edit to a note desktop
deleted no longer looks saved.

* fix(mobile): fail queued review-note requests while disconnected

A notes request queued behind a pending load waited for a reconnect with
no timeout, holding every later save. Notes requests now fail at once when
the connection is down, like terminal input.

* fix(mobile): drop every review-note save built on a failed or dropped one

A save made while a dropped save was still pending carried its change,
so an older host could store it. Each failed or dropped save now moves
the failure count.

* fix(mobile): send each review-note save as the host's list plus its change

Each queued save now builds its payload when it is sent, from the host's
last list plus only its own change. A note added before the first load
no longer replaces the loaded notes on an older host, and a failed save's
change never rides along, so later saves no longer need dropping.
Delivered-note cleanup waits for a reconnect again so those notes are not
sent twice.

---------

Co-authored-by: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com>
2026-10-29 00:40:56 +00:00

228 lines
8.2 KiB
TypeScript

import { z } from 'zod'
import { normalizeExecutionHostId } from '../execution-host'
import { isTuiAgent } from '../tui-agent-config'
import type { TuiAgent } from '../tui-agent'
import {
OptionalBoolean,
OptionalFiniteNumber,
OptionalPlainString,
OptionalString,
TriStateLinkedIssue
} from './rpc-param-primitives'
import { RUNTIME_NAVIGATION_TARGETS } from '../runtime-navigation'
import { WorkspaceLinkedItemSchema } from '../workspace-linked-item-schema'
import { WorkspaceAttachmentsSchema } from '../workspace-attachment-schema'
import { DiffCommentSchema, MobileDiffReviewSchema } from '../diff-comment-schema'
import { TaskSourceContextSchema } from '../task-source-context-schema'
import { isWorkspaceLinkedItemSourceContextMatch } from '../workspace-linked-item-source-context'
export const OptionalExecutionHostId = z
.string()
.transform((value, ctx) => {
const hostId = normalizeExecutionHostId(value)
if (!hostId) {
ctx.addIssue({ code: 'custom', message: 'Invalid host id' })
return z.NEVER
}
return hostId
})
.optional()
export const OptionalTuiAgent = z
.unknown()
.superRefine((value, ctx) => {
if (value !== undefined && !isTuiAgent(value)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Unknown TUI agent' })
}
})
.transform((value): TuiAgent | undefined => (isTuiAgent(value) ? value : undefined))
.optional()
export const AutomationWorkspaceProvenanceRequest = z.object({
automationId: z.string(),
automationRunId: z.string(),
dispatchToken: z.string(),
createRequestId: z.string()
})
// Descriptive provenance grants no authority; the host stamps createdAt to avoid client-clock sort skew.
export const CliWorkspaceProvenanceRequest = z.object({
callerTerminalHandle: OptionalString
})
export const WorktreeListParams = z.object({
repo: OptionalString,
limit: OptionalFiniteNumber
})
export const WorktreeDetectedListParams = z.object({
repo: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing repo selector'))
})
export const WorktreeTeardownMissingTerminalsParams = WorktreeDetectedListParams.extend({
worktreeIds: z.array(z.string().min(1)).max(10_000),
connectionId: z.string().nullable().optional()
})
export const WorktreePsParams = z.object({
limit: OptionalFiniteNumber,
afterSnapshotId: z.string().min(1).max(128).nullable().optional(),
supportsWorktreeVisibilitySourceDefaults: z.literal(true).optional(),
// Opt-in: wait lookups read screens and probe PTYs, too costly for pollers; older hosts omit agentWait.
includeAgentWait: z.literal(true).optional()
})
export const WorktreeSortOrder = z.object({
orderedIds: z.array(z.string()),
// Parallel to orderedIds: the copy each id names, so a same-id twin keeps its own order.
hostIds: z.array(z.string()).optional()
})
export const WorktreeSelector = z.object({
worktree: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing worktree selector'))
})
// Why optional: an older host strips the unknown field and marks its chats asleep as before.
export const WorktreeSleepParams = WorktreeSelector.extend({
markChatsAsleep: z.boolean().optional()
})
export const WorktreeActivate = WorktreeSelector.extend({
notifyClients: OptionalBoolean,
navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional()
})
/** Shared by WorktreeCreate and WorktreeSet so the two error messages cannot drift. */
export function assertLinkedWorkItemSourceContextMatch(
params: {
linkedWorkItem?: z.infer<typeof WorkspaceLinkedItemSchema> | null
linkedTaskSourceContext?: z.infer<typeof TaskSourceContextSchema> | null
},
ctx: z.RefinementCtx
): void {
if (
params.linkedWorkItem &&
params.linkedTaskSourceContext &&
!isWorkspaceLinkedItemSourceContextMatch(params.linkedWorkItem, params.linkedTaskSourceContext)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Linked work item and source context identities must match'
})
}
}
export const WorktreeSet = WorktreeSelector.extend({
// Empty clears to branch/folder fallback; OptionalString would drop remote clears and restore the old name.
displayName: OptionalPlainString,
// Preserve empty comment updates instead of coercing them to absent.
comment: OptionalPlainString,
linkedIssue: TriStateLinkedIssue,
linkedPR: TriStateLinkedIssue,
suppressedGitHubPR: z.number().int().positive().nullable().optional(),
linkedLinearIssue: z.union([z.string(), z.null()]).optional(),
linkedLinearIssueWorkspaceId: z.union([z.string(), z.null()]).optional(),
linkedLinearIssueOrganizationUrlKey: z.union([z.string(), z.null()]).optional(),
linkedGitLabMR: TriStateLinkedIssue,
linkedGitLabIssue: TriStateLinkedIssue,
linkedBitbucketPR: TriStateLinkedIssue,
linkedAzureDevOpsPR: TriStateLinkedIssue,
linkedGiteaPR: TriStateLinkedIssue,
linkedWorkItem: WorkspaceLinkedItemSchema.nullable().optional(),
linkedItems: WorkspaceAttachmentsSchema.optional(),
linkedItemsBase: WorkspaceAttachmentsSchema.optional(),
linkedItemsSelectionChanged: z.boolean().optional(),
linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(),
isArchived: OptionalBoolean,
isUnread: OptionalBoolean,
isPinned: OptionalBoolean,
sortOrder: OptionalFiniteNumber,
manualOrder: OptionalFiniteNumber,
lastActivityAt: OptionalFiniteNumber,
createdAt: OptionalFiniteNumber,
sparseDirectories: z.array(z.string()).optional(),
sparseBaseRef: OptionalString,
sparsePresetId: OptionalString,
baseRef: OptionalString,
workspaceStatus: OptionalString,
pushTarget: z
.object({
remoteName: z.string(),
branchName: z.string(),
remoteUrl: OptionalString
})
.nullable()
.optional(),
// Older clients may send unsupported review shapes alongside unrelated metadata edits.
diffComments: z.array(DiffCommentSchema).optional().catch(undefined),
// The list the writer started from; the host applies only its changes so other devices' notes survive.
diffCommentsBase: z.array(DiffCommentSchema).optional().catch(undefined),
mobileDiffReview: MobileDiffReviewSchema.optional().catch(undefined),
parentWorktree: OptionalString,
noParent: OptionalBoolean
}).superRefine((params, ctx) => {
assertLinkedWorkItemSourceContextMatch(params, ctx)
if (params.parentWorktree && params.noParent === true) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Choose either --parent-worktree or --no-parent, not both.'
})
}
})
export const WorktreeRemove = WorktreeSelector.extend({
hostId: OptionalExecutionHostId,
force: OptionalBoolean,
// Desktop force also means ordinary confirmed delete; PTY-stop waivers need their own field (#11960).
allowUnverifiedPtyStop: OptionalBoolean,
runHooks: OptionalBoolean,
// Waive failed-hook refusal explicitly and record it; independent of force and whether hooks run (#19334).
allowFailedArchiveHook: OptionalBoolean
})
export const WorktreeForceDeleteBranch = WorktreeSelector.extend({
hostId: OptionalExecutionHostId,
branchName: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing branch name')),
expectedHead: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing expected branch head'))
})
export const WorktreeResolvePrBase = z.object({
repo: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing repo selector')),
prNumber: z
.unknown()
.transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0))
.pipe(z.number().int().positive('Missing PR number')),
headRefName: OptionalString,
baseRefName: OptionalString,
isCrossRepository: OptionalBoolean
})
export const WorktreeResolveMrBase = z.object({
repo: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing repo selector')),
mrIid: z
.unknown()
.transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0))
.pipe(z.number().int().positive('Missing MR number')),
sourceBranch: OptionalString,
targetBranch: OptionalString,
isCrossRepository: OptionalBoolean
})