Files
orca/src/preload/api/worktrees-bridge.ts
T
Brennan Benson fd20d34362 Save the issue title on GitHub issue links made without one (#40853)
* fix(github): title GitHub issue links that were saved without one

A workspace linked to a GitHub issue from the CLI (`--issue N`), a phone, the paired web
client or a typed number kept only the number, so search could match its issue title only
while a client happened to hold that issue. The host that owns the workspace now reads each
newly linked untitled issue once, after the write returns, the way its card would, and saves
the title on the link. It keeps the current review selection and never resurrects a removed link.

* fix(github): bound and publish issue-title fills like other metadata writes

Skip writes that leave links untouched before normalizing, check a queued link is still wanted
before reading it, publish filled titles once per repo per 2 s window, refresh the resolved rows
remote clients read, never wake a stopped WSL distro for a background read, keep a selected
review's PR suppression, and keep a failing write observer from failing a saved write.

* fix(github): keep link-title fills off unrelated writes and out of per-title refreshes

Ignore linkedWorkItem in the unchanged-links check (it is rebuilt on every write and always
titled), publish filled titles at once on a quiet host and at most once per repo per 30 s
during a burst on a timer, and only lift a PR suppression when the selected PR changes, so
restating the current PR (as a title fill does) keeps a hidden branch PR hidden.

* perf(github): publish filled link titles as addressed workspace metadata

Instead of a whole-repo worktreesChanged refresh per burst window, the host publishes one
addressed workspaceMetadata set record {hostId, epoch, seq, id, row} for the workspace whose
link was titled: to its desktop window over IPC and to paired clients as a runtime client event.
Clients patch that one row (Cmd-J reads it) and drop replayed records; older clients ignore the
new event and see the title on their next listing.

* fix(github): agreed workspaceMetadata record shape, per-id seqs and late-listing refill

Records name their kind and domain, clients keep the last applied seq per workspace for each
producer epoch (dropped when the epoch changes), and a listing that read metadata before the
title write cannot drop the title: recorded titles refill rows that still hold the same untitled
link for 60 s, never restoring a removed link. The IPC channel is marked as interim transport.

* fix(github): title-only, host-addressed application of workspaceMetadata records

Clients fill the record's issue titles into the named row's still-untitled links and never take
its links over the row's current ones, so a record that arrives after an unlink cannot restore
the issue. A paired host's records reach the row of their own execution host (an SSH twin is a
different row), and go through the bridge's connection-generation check like other host events.

* test(github): type the metadata-record test helper as AppState

* fix(github): keep recorded titles until expiry, refill only changed repos, retire with the pairing

A record that beats the listing adding its link keeps its titles for the full window; the refill
runs only for repos whose listing changed, indexes that repo's rows once and skips rows already
checked; per-workspace seqs live in the expiring entries; a re-pair or reconnect of the producing
environment retires its titles.

* fix(github): read the listing through the workspace record selector

* fix(github): keep link titles until their producer changes; fall back to a listing on eviction

Recorded issue titles no longer expire on a timer (a slow listing could outlast it): they live until
the producer's epoch, pairing or connection changes, refilling only present untitled links. When
more than the cap are in flight the oldest is evicted and its repo is listed afresh, which carries
the saved title. The refill runs in updater form so it never merges a stale store snapshot.

* fix(github): sequenced listings replace the retained-title refill

Per the re-plan ruling: the store keeps one workspaceMetadata cursor per process; a title record takes
the next seq in the same turn as its write, and each listing records the cursor when it reads metadata.
Clients note each record's seq per producer and repo; a listing at or past it ends the obligation, an
older one keeps titles the rows already show and lists the repo afresh (a new invocation, never joining
the stale one), a record whose row or link isn't listed yet asks for a fresh listing, and a seq gap
lists every repo of that producer. The retained-title table, its watcher, TTL and cap are gone.

* test(github): stamp capture, gap, restart and recovery coverage for workspace metadata listings

Also retire an environment's earlier coverage bookkeeping when it is re-paired or reconnects.

* fix(space): keep issue-title coverage per repo and host until a newer listing lands

Coverage now keeps each projection's newest record seq after its covering listing settles, so an older listing that finishes later still keeps shown titles. Projections are keyed by repo and host, paired producers keep their stream cursor, retired pairings and unlisted projections are dropped, every listing served by this host carries its metadata stamp, and a record with any unlisted titled link asks for a listing.


* fix(space): re-list after any stale listing and drop retired metadata producers

A stale listing now always asks for one fresh listing, so a link it dropped comes back and a failed scan can no longer end the wait. Removed environments drop their producer state, the producer's repo list is built only on a gap or a first-seen projection, and recovery listings skip the lineage refresh.


* perf(space): filter current rows only for listings that keep titles


* fix(space): forget removed repos on a metadata gap; keep titles for another producer run's listing


* fix(palette): search a linked issue by its saved title before its card renders

The issue's search entry took its title only from the issue cache, which fills when the card renders, and it shadowed the saved link's titled entry under the same id. It now falls back to the link's saved title.


* perf(palette): read a row's links once per palette document


* fix(palette): give each saved link its own search unit

Saved-link evidence was meant to carry an `attachment:` id but the override set an unused field, so a link shared the `issue:N` unit id with the legacy issue field and with same-number links from other repos, and only the first (often untitled) survived. The id now applies to the unit and its fields, so every saved link's title is searchable. Replaces the cache-title fallback.


* fix(space): keep the title queue draining past a failed link; encode environment ids in coverage keys


---------
2026-11-10 16:38:44 -08:00

131 lines
5.4 KiB
TypeScript

import { ipcRenderer } from 'electron'
import type {
WorktreeBaseStatusEvent,
WorktreeRemoteBranchConflictEvent
} from '../../shared/worktree/base-ref-drift-types'
import type { CreateWorktreeProgressEvent } from '../../shared/worktree/create-types'
import type {
GitStatusMetadataChangedEvent,
WorktreeHeadIdentity
} from '../../shared/worktree/types'
import type { PreloadApi } from '../api-types'
import type { WorkspaceMetadataSet } from '../../shared/worktree/workspace-metadata-set'
export const worktreesApi = {
listRetiredNames: (args) => ipcRenderer.invoke('worktrees:listRetiredNames', args),
listDetected: (args) => ipcRenderer.invoke('worktrees:listDetected', args),
listKnownForExecutionHost: (args) =>
ipcRenderer.invoke('worktrees:listKnownForExecutionHost', args),
forgetRemovedForExecutionHost: (args) =>
ipcRenderer.invoke('worktrees:forgetRemovedForExecutionHost', args),
cancelListDetected: (args) => ipcRenderer.invoke('worktrees:cancelListDetected', args),
scanStartingWslDistro: (args) => ipcRenderer.invoke('worktrees:scanStartingWslDistro', args),
create: (args) => ipcRenderer.invoke('worktrees:create', args),
adoptProvisionedRoot: (args) => ipcRenderer.invoke('worktrees:adoptProvisionedRoot', args),
runApprovedSetup: (args) => ipcRenderer.invoke('worktrees:runApprovedSetup', args),
onCreateProgress: (callback: (data: CreateWorktreeProgressEvent) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: CreateWorktreeProgressEvent) =>
callback(data)
ipcRenderer.on('createWorktree:progress', listener)
return () => ipcRenderer.removeListener('createWorktree:progress', listener)
},
prefetchCreateBase: (args) => ipcRenderer.invoke('worktrees:prefetchCreateBase', args),
resolvePrBase: (args) => ipcRenderer.invoke('worktrees:resolvePrBase', args),
resolveMrBase: (args) => ipcRenderer.invoke('worktrees:resolveMrBase', args),
previewNestedRemoval: (args) => ipcRenderer.invoke('worktrees:previewNestedRemoval', args),
sleep: (request) => ipcRenderer.invoke('worktrees:sleep', request),
sleepReopened: (worktreeId) => ipcRenderer.invoke('worktrees:sleepReopened', worktreeId),
resumeSleptPane: (pane, automatic) =>
ipcRenderer.invoke('worktrees:resumeSleptPane', pane, automatic),
retireStrandedSleepRecord: (args) =>
ipcRenderer.invoke('worktrees:retireStrandedSleepRecord', args),
remove: (args) => ipcRenderer.invoke('worktrees:remove', args),
forgetLocal: (args) => ipcRenderer.invoke('worktrees:forgetLocal', args),
forceDeletePreservedBranch: (args) =>
ipcRenderer.invoke('worktrees:forceDeletePreservedBranch', args),
updateMeta: (args) => ipcRenderer.invoke('worktrees:updateMeta', args),
listLineage: () => ipcRenderer.invoke('worktrees:listLineage'),
updateLineage: (args) => ipcRenderer.invoke('worktrees:updateLineage', args),
persistSortOrder: (args) => ipcRenderer.invoke('worktrees:persistSortOrder', args),
getBranchRenameFailureOutput: (args) =>
ipcRenderer.invoke('worktrees:getBranchRenameFailureOutput', args),
onChanged: (
callback: (data: {
repoId: string
renamed?: { oldWorktreeId: string; newWorktreeId: string }
}) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: { repoId: string; renamed?: { oldWorktreeId: string; newWorktreeId: string } }
) => callback(data)
ipcRenderer.on('worktrees:changed', listener)
return () => ipcRenderer.removeListener('worktrees:changed', listener)
},
onGitStatusMetadataChanged: (
callback: (data: GitStatusMetadataChangedEvent) => void
): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: GitStatusMetadataChangedEvent) =>
callback(data)
ipcRenderer.on('worktrees:gitStatusMetadataChanged', listener)
return () => ipcRenderer.removeListener('worktrees:gitStatusMetadataChanged', listener)
},
onHeadIdentitiesChanged: (
callback: (data: { repoId: string; identities: WorktreeHeadIdentity[] }) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: { repoId: string; identities: WorktreeHeadIdentity[] }
) => callback(data)
ipcRenderer.on('worktrees:headIdentitiesChanged', listener)
return () => ipcRenderer.removeListener('worktrees:headIdentitiesChanged', listener)
},
onMetadataSet: (callback: (set: WorkspaceMetadataSet) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, set: WorkspaceMetadataSet) => callback(set)
ipcRenderer.on('worktrees:metadataSet', listener)
return () => ipcRenderer.removeListener('worktrees:metadataSet', listener)
},
onBaseStatus: (callback: (data: WorktreeBaseStatusEvent) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: WorktreeBaseStatusEvent) =>
callback(data)
ipcRenderer.on('worktree:baseStatus', listener)
return () => ipcRenderer.removeListener('worktree:baseStatus', listener)
},
onRemoteBranchConflict: (
callback: (data: WorktreeRemoteBranchConflictEvent) => void
): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: WorktreeRemoteBranchConflictEvent) =>
callback(data)
ipcRenderer.on('worktree:remoteBranchConflict', listener)
return () => ipcRenderer.removeListener('worktree:remoteBranchConflict', listener)
}
} satisfies PreloadApi['worktrees']