Add pending rename badge to workspace cards (#4454)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-06-02 10:50:18 -07:00
committed by GitHub
co-authored by Orca
parent 8d1dff49bd
commit 0a736b1dc6
13 changed files with 170 additions and 20 deletions
+4 -1
View File
@@ -198,7 +198,10 @@ function maybeAutoRenameBranchOnFirstWorkFromHook(event: {
return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true
},
setDisplayName: (worktreeId, displayName) => {
currentStore.setWorktreeMeta(worktreeId, { displayName })
currentStore.setWorktreeMeta(worktreeId, {
displayName,
pendingFirstAgentMessageRename: false
})
},
resolveWorktreeIdForTab: (tabId) => currentStore.getWorktreeIdForTab(tabId),
onRenamed: (repoId) => currentRuntime.notifyBranchRenamed(repoId)
+3
View File
@@ -300,6 +300,9 @@ export function mergeWorktree(
lastActivityAt: meta?.lastActivityAt ?? 0,
...(meta?.createdAt !== undefined ? { createdAt: meta.createdAt } : {}),
...(meta?.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}),
...(meta?.pendingFirstAgentMessageRename !== undefined
? { pendingFirstAgentMessageRename: meta.pendingFirstAgentMessageRename }
: {}),
...(git.isSparse === true
? {
sparseDirectories: meta?.sparseDirectories,
+6
View File
@@ -1374,6 +1374,9 @@ export async function createRemoteWorktree(
? { displayName: effectiveRequestedName }
: {}),
...(isTuiAgent(args.createdWithAgent) ? { createdWithAgent: args.createdWithAgent } : {}),
...(args.pendingFirstAgentMessageRename === true && isTuiAgent(args.createdWithAgent)
? { pendingFirstAgentMessageRename: true }
: {}),
...(sparseDirectories.length > 0
? {
sparseDirectories,
@@ -1801,6 +1804,9 @@ export async function createLocalWorktree(
}
: {}),
...(isTuiAgent(args.createdWithAgent) ? { createdWithAgent: args.createdWithAgent } : {}),
...(args.pendingFirstAgentMessageRename === true && isTuiAgent(args.createdWithAgent)
? { pendingFirstAgentMessageRename: true }
: {}),
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}),
...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}),
+5 -4
View File
@@ -1383,10 +1383,11 @@ export function registerWorktreeHandlers(
ipcMain.handle(
'worktrees:updateMeta',
(_event, args: { worktreeId: string; updates: Partial<WorktreeMeta> }) => {
const meta = store.setWorktreeMeta(
args.worktreeId,
stripOrcaProvenanceMetaUpdates(args.updates)
)
const updates =
args.updates.displayName !== undefined
? { ...args.updates, pendingFirstAgentMessageRename: false }
: args.updates
const meta = store.setWorktreeMeta(args.worktreeId, stripOrcaProvenanceMetaUpdates(updates))
// Do NOT call notifyWorktreesChanged here. The renderer applies meta
// updates optimistically before calling this IPC, so a notification
// would trigger a redundant fetchWorktrees round-trip that bumps
+16 -2
View File
@@ -7601,6 +7601,7 @@ export class OrcaRuntimeService {
activate?: boolean
setupDecision?: 'run' | 'skip' | 'inherit'
createdWithAgent?: TuiAgent
pendingFirstAgentMessageRename?: boolean
startup?: WorktreeStartupLaunch
startupDraft?: string
startupDraftPaste?: WorktreeStartupDraftPaste
@@ -7976,6 +7977,9 @@ export class OrcaRuntimeService {
: {}),
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}),
...(args.pendingFirstAgentMessageRename === true && effectiveCreatedWithAgent
? { pendingFirstAgentMessageRename: true }
: {}),
...(args.comment !== undefined ? { comment: args.comment } : {}),
...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}),
...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {})
@@ -8237,6 +8241,7 @@ export class OrcaRuntimeService {
runHooks?: boolean
setupDecision?: 'run' | 'skip' | 'inherit'
createdWithAgent?: TuiAgent
pendingFirstAgentMessageRename?: boolean
startup?: WorktreeStartupLaunch
startupDraftPaste?: WorktreeStartupDraftPaste
}
@@ -8271,7 +8276,10 @@ export class OrcaRuntimeService {
...(args.pushTarget ? { pushTarget: args.pushTarget } : {}),
...(args.workspaceStatus ? { workspaceStatus: args.workspaceStatus as never } : {}),
...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}),
...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {})
...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}),
...(args.pendingFirstAgentMessageRename === true
? { pendingFirstAgentMessageRename: true }
: {})
},
repo,
this.store as unknown as Store,
@@ -8831,7 +8839,13 @@ export class OrcaRuntimeService {
}
this.store.setWorktreeMeta(
worktree.id,
stripOrcaProvenanceMetaUpdates(omitUndefinedProperties(metaUpdates))
stripOrcaProvenanceMetaUpdates(
omitUndefinedProperties(
metaUpdates.displayName !== undefined
? { ...metaUpdates, pendingFirstAgentMessageRename: false }
: metaUpdates
)
)
)
// Why: unlike renderer-initiated optimistic updates, CLI callers need an
// explicit push so the editor refreshes metadata changed outside the UI.
@@ -171,6 +171,20 @@ describe('WorktreeCard quick actions', () => {
expect(markup).toContain('data-worktree-card-meta-row=""')
})
it('renders the pending first-agent rename title button', () => {
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ pendingFirstAgentMessageRename: true })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('aria-label="Will be renamed from first agent message"')
expect(markup).toContain('rename pending')
expect(markup).toContain('Will be renamed from first agent message')
})
it('renders the repeated branch metadata row in detailed cards', () => {
worktreeCardProperties = []
@@ -12,6 +12,7 @@ import {
LoaderCircle,
Server,
ServerOff,
Sparkles,
Star,
Trash2,
Workflow
@@ -462,6 +463,13 @@ const WorktreeCard = React.memo(function WorktreeCard({
},
[showDeleteQuickAction, worktree.id]
)
const handlePendingFirstAgentMessageRenameInfo = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
event.stopPropagation()
},
[]
)
const unreadTooltip = worktree.isUnread ? 'Mark read' : 'Mark unread'
const childWorkspaceLabel = `${lineageChildCount} child ${
@@ -814,6 +822,28 @@ const WorktreeCard = React.memo(function WorktreeCard({
onRename={handleRenameTitle}
/>
{worktree.pendingFirstAgentMessageRename === true && !titleRenaming ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
onPointerDown={stopQuickActionPointerPropagation}
onClick={handlePendingFirstAgentMessageRenameInfo}
onDoubleClick={handlePendingFirstAgentMessageRenameInfo}
className="h-4 shrink-0 gap-0.5 rounded !px-0.5 text-[10px] font-medium leading-none text-muted-foreground border border-sidebar-border/60 bg-sidebar-accent/45 hover:bg-sidebar-accent hover:text-foreground has-[>svg]:!px-0.5"
aria-label="Will be renamed from first agent message"
>
<Sparkles className="size-2.5" />
rename pending
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Will be renamed from first agent message
</TooltipContent>
</Tooltip>
) : null}
{!compactCards && worktree.isMainWorktree && !isFolder && (
<Tooltip>
<TooltipTrigger asChild>
+31 -4
View File
@@ -1924,6 +1924,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
workspaceName,
preserveWorkspaceNameEdits: branchNameOverridePreservesNameEdits
})
const createDisplayName = smartGitHubResolution?.displayName ?? submitLinkedWorkItem?.title
// Why: the first-work hook only renames blank, auto-generated git workspaces
// that actually launch an agent. Persist that known-pending state for the card.
const pendingFirstAgentMessageRename =
selectedRepoIsGit &&
settings?.autoRenameBranchFromWork === true &&
!name.trim() &&
Boolean(tuiAgent) &&
!effectiveBranchNameOverride &&
!createDisplayName
const result = await createWorktree(
repoId,
workspaceName,
@@ -1936,7 +1946,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
: undefined,
telemetrySource,
smartGitHubResolution?.displayName ?? submitLinkedWorkItem?.title,
createDisplayName,
submitLinkedIssueNumber ?? undefined,
submitLinkedPR ?? undefined,
pushTarget,
@@ -1945,7 +1955,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
effectiveBranchNameOverride,
resolvedInitialWorkspaceStatus,
linkedGitLabMR ?? undefined,
linkedGitLabIssue ?? undefined
linkedGitLabIssue ?? undefined,
undefined,
pendingFirstAgentMessageRename
)
const worktree = result.worktree
@@ -2041,6 +2053,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
linkedGitLabIssue,
linkedGitLabMR,
linkedWorkItem,
name,
normalizedSparseDirectories,
note,
onCreated,
@@ -2056,6 +2069,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
selectedRepoIsGit,
selectedRepoRequiresConnection,
settings?.agentCmdOverrides,
settings?.autoRenameBranchFromWork,
setSidebarOpen,
setupDecision,
sparseEnabled,
@@ -2151,6 +2165,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
workspaceName,
preserveWorkspaceNameEdits: branchNameOverridePreservesNameEdits
})
const createDisplayName = smartGitHubResolution?.displayName ?? submitLinkedWorkItem?.title
// Why: quick create uses the same blank-name creature branch flow; the card
// needs an explicit marker rather than guessing from the generated title.
const pendingFirstAgentMessageRename =
selectedRepoIsGit &&
settings?.autoRenameBranchFromWork === true &&
!name.trim() &&
Boolean(agent) &&
!effectiveBranchNameOverride &&
!createDisplayName
const result = await createWorktree(
repoId,
workspaceName,
@@ -2163,7 +2187,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
: undefined,
telemetrySource,
smartGitHubResolution?.displayName ?? submitLinkedWorkItem?.title,
createDisplayName,
submitLinkedIssueNumber ?? undefined,
submitLinkedPR ?? undefined,
pushTarget,
@@ -2172,7 +2196,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
effectiveBranchNameOverride,
resolvedInitialWorkspaceStatus,
linkedGitLabMR ?? undefined,
linkedGitLabIssue ?? undefined
linkedGitLabIssue ?? undefined,
undefined,
pendingFirstAgentMessageRename
)
const worktree = result.worktree
@@ -2325,6 +2351,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
selectedRepoIsGit,
selectedRepoRequiresConnection,
settings?.agentCmdOverrides,
settings?.autoRenameBranchFromWork,
disabledTuiAgents,
setSidebarOpen,
setupDecision,
@@ -105,7 +105,8 @@ export type WorktreeSlice = {
workspaceStatus?: WorkspaceStatus,
linkedGitLabMR?: number,
linkedGitLabIssue?: number,
startup?: WorktreeStartupLaunch
startup?: WorktreeStartupLaunch,
pendingFirstAgentMessageRename?: boolean
) => Promise<CreateWorktreeResult>
prefetchWorktreeCreateBase: (repoId: string, baseBranch?: string) => Promise<void>
removeWorktree: (
@@ -1100,7 +1100,8 @@ describe('createWorktree base status merge', () => {
linkedPR: 456,
createdWithAgent: 'codex',
linkedLinearIssue: 'ENG-123',
workspaceStatus: 'in-review'
workspaceStatus: 'in-review',
pendingFirstAgentMessageRename: true
})
mockApi.worktrees.create.mockResolvedValue({ worktree: wt })
@@ -1120,7 +1121,11 @@ describe('createWorktree base status merge', () => {
'codex',
'ENG-123',
undefined,
'in-review'
'in-review',
undefined,
undefined,
undefined,
true
)
expect(mockApi.worktrees.create).toHaveBeenCalledWith(
@@ -1131,7 +1136,8 @@ describe('createWorktree base status merge', () => {
linkedPR: 456,
createdWithAgent: 'codex',
linkedLinearIssue: 'ENG-123',
workspaceStatus: 'in-review'
workspaceStatus: 'in-review',
pendingFirstAgentMessageRename: true
})
)
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
@@ -1139,7 +1145,8 @@ describe('createWorktree base status merge', () => {
linkedPR: 456,
createdWithAgent: 'codex',
linkedLinearIssue: 'ENG-123',
workspaceStatus: 'in-review'
workspaceStatus: 'in-review',
pendingFirstAgentMessageRename: true
})
})
@@ -1938,6 +1945,31 @@ describe('worktree remote runtime mutations', () => {
expect(store.getState().worktreesByRepo.repo1[0]?.comment).toBe('remote note')
})
it('clears pending first-agent rename when the title is updated', async () => {
const store = createTestStore()
const wt = makeWorktree({
id: 'repo1::/path/wt1',
repoId: 'repo1',
path: '/path/wt1',
displayName: 'Nautilus',
pendingFirstAgentMessageRename: true
})
store.setState({
worktreesByRepo: { repo1: [wt] }
} as Partial<AppState>)
await store.getState().updateWorktreeMeta(wt.id, { displayName: 'Fix auth' })
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
worktreeId: wt.id,
updates: { displayName: 'Fix auth', pendingFirstAgentMessageRename: false }
})
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
displayName: 'Fix auth',
pendingFirstAgentMessageRename: false
})
})
it('resolves and persists a push target when manually linking a GitHub PR', async () => {
const store = createTestStore()
const pushTarget = { remoteName: 'origin', branchName: 'bot/pr-bug-scan-2504' }
+14 -4
View File
@@ -186,6 +186,7 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b
worktree.lastActivityAt === candidate.lastActivityAt &&
worktree.workspaceStatus === candidate.workspaceStatus &&
worktree.createdWithAgent === candidate.createdWithAgent &&
worktree.pendingFirstAgentMessageRename === candidate.pendingFirstAgentMessageRename &&
worktree.baseRef === candidate.baseRef &&
worktree.pushTarget?.remoteName === candidate.pushTarget?.remoteName &&
worktree.pushTarget?.branchName === candidate.pushTarget?.branchName &&
@@ -1001,7 +1002,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
workspaceStatus,
linkedGitLabMR,
linkedGitLabIssue,
startup
startup,
pendingFirstAgentMessageRename
) => {
const retryableConflictPatterns = [
/already exists locally/i,
@@ -1040,6 +1042,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
...(linkedPR !== undefined ? { linkedPR } : {}),
...(pushTarget ? { pushTarget } : {}),
...(createdWithAgent ? { createdWithAgent } : {}),
...(pendingFirstAgentMessageRename === true && createdWithAgent
? { pendingFirstAgentMessageRename: true }
: {}),
...(linkedLinearIssue !== undefined ? { linkedLinearIssue } : {}),
...(manualOrder !== undefined ? { manualOrder } : {}),
...(workspaceStatus !== undefined ? { workspaceStatus } : {}),
@@ -1066,6 +1071,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
...(linkedPR !== undefined ? { linkedPR } : {}),
...(pushTarget ? { pushTarget } : {}),
...(createdWithAgent ? { createdWithAgent } : {}),
...(pendingFirstAgentMessageRename === true && createdWithAgent
? { pendingFirstAgentMessageRename: true }
: {}),
...(linkedLinearIssue !== undefined ? { linkedLinearIssue } : {}),
...(manualOrder !== undefined ? { manualOrder } : {}),
...(workspaceStatus !== undefined ? { workspaceStatus } : {}),
@@ -1506,10 +1514,12 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
const targetEnriched = resolvedPushTarget
? { ...updates, pushTarget: resolvedPushTarget }
: updates
const enriched =
'comment' in targetEnriched
? { ...targetEnriched, lastActivityAt: Date.now() }
const renameCleared =
'displayName' in targetEnriched
? { ...targetEnriched, pendingFirstAgentMessageRename: false }
: targetEnriched
const enriched =
'comment' in renameCleared ? { ...renameCleared, lastActivityAt: Date.now() } : renameCleared
let didApply = false
set((s) => {
+1
View File
@@ -1009,6 +1009,7 @@ function createWorktreesApi(): NonNullable<Partial<PreloadApi>['worktrees']> {
pushTarget: args.pushTarget,
setupDecision: args.setupDecision,
createdWithAgent: args.createdWithAgent,
pendingFirstAgentMessageRename: args.pendingFirstAgentMessageRename,
workspaceStatus: args.workspaceStatus,
manualOrder: args.manualOrder
})
+8
View File
@@ -260,6 +260,9 @@ export type Worktree = {
* seed a replacement terminal if the user later reopens the worktree after
* closing every visible surface. */
createdWithAgent?: TuiAgent
/** True while an auto-named workspace is waiting for the first agent message
* to drive the branch/title rename. */
pendingFirstAgentMessageRename?: boolean
sparseDirectories?: string[]
sparseBaseRef?: string
/** ID of the saved preset this worktree was created from, if any. Cleared
@@ -314,6 +317,8 @@ export type WorktreeMeta = {
createdAt?: number
/** See {@link Worktree.createdWithAgent}. Persisted to orca-data.json. */
createdWithAgent?: TuiAgent
/** See {@link Worktree.pendingFirstAgentMessageRename}. */
pendingFirstAgentMessageRename?: boolean
sparseDirectories?: string[]
sparseBaseRef?: string
sparsePresetId?: string
@@ -1578,6 +1583,9 @@ export type CreateWorktreeArgs = {
manualOrder?: number
/** Agent selected in the create surface. Omitted for blank-shell creates. */
createdWithAgent?: TuiAgent
/** Set when the renderer knows this auto-generated branch should be renamed
* from the first agent message. */
pendingFirstAgentMessageRename?: boolean
/** Telemetry-only: which UI surface initiated this create. Threaded from
* the renderer entry point so main can emit `workspace_created` with the
* correct `source`. `unknown` is a valid wire value — an unrecognized