From 0a736b1dc6b51b53b91cfa5000e634abc06f54d3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:50:18 -0700 Subject: [PATCH] Add pending rename badge to workspace cards (#4454) Co-authored-by: Orca --- src/main/index.ts | 5 ++- src/main/ipc/worktree-logic.ts | 3 ++ src/main/ipc/worktree-remote.ts | 6 +++ src/main/ipc/worktrees.ts | 9 +++-- src/main/runtime/orca-runtime.ts | 18 ++++++++- .../WorktreeCard.quick-actions.test.tsx | 14 +++++++ .../src/components/sidebar/WorktreeCard.tsx | 30 ++++++++++++++ src/renderer/src/hooks/useComposerState.ts | 35 ++++++++++++++-- .../src/store/slices/worktree-helpers.ts | 3 +- .../src/store/slices/worktrees.test.ts | 40 +++++++++++++++++-- src/renderer/src/store/slices/worktrees.ts | 18 +++++++-- src/renderer/src/web/web-preload-api.ts | 1 + src/shared/types.ts | 8 ++++ 13 files changed, 170 insertions(+), 20 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 71e9fc3c09d..40ea6b67802 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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) diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 13e6038280a..9fe960cf4f8 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -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, diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 56339a9da8f..0f2c4a8f461 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -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 } : {}), diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 54649cc53db..3d91cb93413 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -1383,10 +1383,11 @@ export function registerWorktreeHandlers( ipcMain.handle( 'worktrees:updateMeta', (_event, args: { worktreeId: string; updates: Partial }) => { - 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 diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 84b45a361cf..a799946382a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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. diff --git a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx index 34a544e1c7a..1535aa3bf21 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx @@ -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( + + ) + + 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 = [] diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 476622e8b8f..f2534b542df 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -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) => { + 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 ? ( + + + + + + Will be renamed from first agent message + + + ) : null} + {!compactCards && worktree.isMainWorktree && !isFolder && ( diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index dd812472e8a..06fc85ba2b3 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -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, diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 8cd3171194e..5ec36885d6b 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -105,7 +105,8 @@ export type WorktreeSlice = { workspaceStatus?: WorkspaceStatus, linkedGitLabMR?: number, linkedGitLabIssue?: number, - startup?: WorktreeStartupLaunch + startup?: WorktreeStartupLaunch, + pendingFirstAgentMessageRename?: boolean ) => Promise prefetchWorktreeCreateBase: (repoId: string, baseBranch?: string) => Promise removeWorktree: ( diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 824472f98ea..eb925643bba 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -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) + + 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' } diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 15e8e74d3c3..2696eafbd0e 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -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 workspaceStatus, linkedGitLabMR, linkedGitLabIssue, - startup + startup, + pendingFirstAgentMessageRename ) => { const retryableConflictPatterns = [ /already exists locally/i, @@ -1040,6 +1042,9 @@ export const createWorktreeSlice: StateCreator ...(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 ...(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 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) => { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 492be8e525e..1616ff3f242 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1009,6 +1009,7 @@ function createWorktreesApi(): NonNullable['worktrees']> { pushTarget: args.pushTarget, setupDecision: args.setupDecision, createdWithAgent: args.createdWithAgent, + pendingFirstAgentMessageRename: args.pendingFirstAgentMessageRename, workspaceStatus: args.workspaceStatus, manualOrder: args.manualOrder }) diff --git a/src/shared/types.ts b/src/shared/types.ts index 2298380ae38..4320d4a7a83 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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