diff --git a/src/renderer/src/components/NativeChatResumeOnRestartGroups.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartGroups.test.tsx index 70c7dfdbf24e..e47603e8f81c 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartGroups.test.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartGroups.test.tsx @@ -4,7 +4,8 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, expect, it } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import * as worktreeIds from '../../../shared/worktree/id' import { useAppStore } from '../store' import { getDefaultSettings } from '../../../shared/constants' import { getHostContextLabel } from '../../../shared/worktree/host-context-labels' @@ -41,6 +42,7 @@ beforeEach(() => { afterEach(() => { act(() => root.unmount()) + vi.restoreAllMocks() container.remove() useAppStore.setState(useAppStore.getInitialState(), true) }) @@ -207,6 +209,97 @@ it('names a workspace the store does not know by its id, with the kind the host expect(chatBox('lost').getAttribute('aria-label')).toContain('in folder:missing-folder') }) +it('names a git worktree the store does not know by its folder, not its repo-qualified id', () => { + render({ candidates: [candidate('lost', worktree('blobfish-2'))] }) + + expect(nodeBox('blobfish-2')).not.toBeNull() + expect(chatBox('lost').getAttribute('aria-label')).toContain('in blobfish-2') + expect(container.textContent).not.toContain('repo-1::') +}) + +it('disambiguates only colliding unknown folders with linear display work on render', () => { + const known = worktree('main', { displayName: 'Configured', hostId: 'local' }) + useAppStore.setState({ worktreesByRepo: { 'repo-1': [known] } }) + const controls = [ + candidate('known', known), + candidate('unique', worktree('blobfish-2')), + candidate('opaque', worktree('opaque', { id: 'folder:missing-folder' })) + ] + const basename = vi.spyOn(worktreeIds, 'getWorktreePathBasenameFromId') + let nameReads = 0 + const mapSet = Map.prototype.set + vi.spyOn(Map.prototype, 'set').mockImplementation(function ( + this: Map, + key, + value + ) { + if (typeof value?.parentPath === 'string' && value.name === 'main') { + const name = value.name + Object.defineProperty(value, 'name', { + get: () => { + nameReads++ + return name + } + }) + } + return mapSet.call(this, key, value) + }) + const operations: number[] = [] + let unknown: ResumeCandidate[] = [] + for (const size of [10, 100]) { + unknown = Array.from({ length: size }, (_, index) => + candidate( + `lost-${index}`, + worktree('main', { + id: `repo-1::/Users/test/projects/organization/orca/workspaces/client-${index}/main` + }) + ) + ) + basename.mockClear() + nameReads = 0 + render({ candidates: [...controls, ...unknown] }) + const parses = basename.mock.calls.filter(([id]) => id.startsWith('repo-1::/Users/')).length + const work = parses + nameReads + expect(Math.min(parses, nameReads)).toBeGreaterThanOrEqual(size) + expect(work).toBeLessThanOrEqual(6 * size) + operations.push(work) + for (const index of [0, 1]) { + const parent = `/Users/test/projects/organization/orca/workspaces/client-${index}` + const detail = container.querySelector(`[title="${parent}"]`) + expect(detail?.textContent).toBe(parent) + expect(detail?.lastElementChild?.textContent).toBe(`/workspaces/client-${index}`) + const label = `main (${parent})` + const row = itemOf(nodeBox(label)) + expect(row.querySelector('.font-medium')?.textContent).toBe('main') + expect(row.querySelector('button[aria-expanded]')?.getAttribute('aria-label')).toBe( + `Collapse ${label}` + ) + expect(chatBox(`lost-${index}`).getAttribute('aria-label')).toContain(`in ${label}`) + } + expect(itemOf(nodeBox('Configured')).textContent).toContain('main-branch') + expect(itemOf(nodeBox('blobfish-2')).querySelector('[title]')).toBeNull() + expect(nodeBox('folder:missing-folder')).not.toBeNull() + } + expect(operations[1]).toBeLessThanOrEqual(12 * operations[0]!) + const collisionBoxes = container.querySelectorAll( + '[role="checkbox"][aria-label^="Select all chats in main ("]' + ) + act(() => collisionBoxes[1]!.click()) + expect(onToggleSpy).toHaveBeenCalledExactlyOnceWith('lost-1', false) + render({ candidates: unknown.slice(0, 2) }) + const hydrated = { ...known, id: unknown[0]!.workspaceId, displayName: 'Resolved' } + act(() => useAppStore.setState({ worktreesByRepo: { 'repo-1': [known, hydrated] } })) + expect(nodeBox('Resolved')).not.toBeNull() + expect(itemOf(nodeBox('main')).querySelector('[title]')).toBeNull() + expect(container.querySelector('[aria-label^="Select all chats in main ("]')).toBeNull() + expect( + itemOf(nodeBox('main')).querySelector('button[aria-expanded]')?.getAttribute('aria-label') + ).toBe('Collapse main') + expect(chatBox('lost-1').getAttribute('aria-label')).toBe( + 'Resume Codex chat "Prompt lost-1" in main' + ) +}) + // Its project is unknown too: a nameless "Select all chats in " checkbox would help no one. it('gives workspaces the store cannot place no project node', () => { const unknown: ResumeCandidate = { diff --git a/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx b/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx index 42d5cc23301c..d412245c332f 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx @@ -1,13 +1,11 @@ import { useContext, useMemo } from 'react' import { Folder, FolderTree, GitBranch, Monitor } from 'lucide-react' import { RepoIconGlyph } from '@/components/repo/repo-icon' +import { ProjectOptionDetail } from '@/components/new-workspace/ProjectComboboxRow' import { WorktreeHostContextBadge } from '@/components/sidebar/WorktreeHostContextBadge' import { useSidebarHostScopeOptions } from '@/components/sidebar/use-sidebar-host-scope-options' import { translate } from '@/i18n/i18n' -import { - resolveWorktreeBranchLabel, - resolveWorktreeDisplayName -} from '@/lib/worktree-default-display-name' +import { resolveWorktreeBranchLabel } from '@/lib/worktree-default-display-name' import { getHostContextLabel } from '../../../shared/worktree/host-context-labels' import { LOCAL_EXECUTION_HOST_ID, @@ -15,6 +13,7 @@ import { type ExecutionHostId } from '../../../shared/execution-host' import { useAppStore } from '../store' +import * as workspaceDisplay from './native-chat-resume-workspace-display' import { useLineageAncestors, useRepoIdByWorkspace, @@ -57,7 +56,8 @@ export type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' * level shows only when the machine is not obvious (a remote host, or chats on more than one), and * then it is what names the host, so no workspace repeats it. * - * A workspace the store does not know yet (host connecting, or deleted) is named by its id. + * A workspace the store does not know yet (host connecting, or deleted) is named by its folder, the + * name its id carries. */ /** A group node: its row, then (while open) what is under it. */ @@ -111,12 +111,14 @@ function WorkspaceNode({ node, hostId, depth, - tree + tree, + fallbackDisplays }: { node: ResumeWorkspaceNode hostId: ExecutionHostId depth: number tree: TreeProps + fallbackDisplays?: workspaceDisplay.WorkspaceFallbacks }): React.JSX.Element { const { group } = node const first = group.candidates[0] @@ -124,17 +126,18 @@ function WorkspaceNode({ const kind = first ? resumeWorkspaceKind(first) : 'git-worktree' // Kind comes from the host's record, never from a name. const KindGlyph = kind === 'folder' ? Folder : GitBranch - const name = (worktree && resolveWorktreeDisplayName(worktree)) || group.workspaceId + const fallback = fallbackDisplays?.get(group) + const { name, controlName } = workspaceDisplay.resolveNames(worktree, fallback, group.workspaceId) const branch = worktree && kind === 'git-worktree' ? resolveWorktreeBranchLabel(worktree) : '' return ( )} + {!worktree && fallback?.parentPath && ( + + )} } > @@ -157,7 +166,7 @@ function WorkspaceNode({ ))} @@ -272,6 +282,7 @@ function MachineContents({ <> {groupResumeWorkspacesByRepo(workspaces, lookup(tree.repoIdOf, null)).map((repoGroup) => { const nodes = nestResumeWorkspaces(repoGroup.workspaces, ancestorsOf) + const fallbackDisplays = workspaceDisplay.deriveFallbacks(repoGroup) // A group the store cannot place has no project to name or select; Select all covers it. return repoGroup.repoId === null ? ( nodes.map((node) => ( @@ -281,6 +292,7 @@ function MachineContents({ hostId={hostId} depth={depth} tree={tree} + fallbackDisplays={fallbackDisplays} /> )) ) : ( diff --git a/src/renderer/src/components/native-chat-resume-workspace-display.ts b/src/renderer/src/components/native-chat-resume-workspace-display.ts new file mode 100644 index 000000000000..ec3b1f776dd0 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-workspace-display.ts @@ -0,0 +1,48 @@ +import { dirname } from '@/lib/path' +import { resolveWorktreeDisplayName } from '@/lib/worktree-default-display-name' +import { + getWorktreePathBasenameFromId, + splitWorktreeIdForFilesystem +} from '../../../shared/worktree/id' +import type { + ResumeRepoGroup, + ResumeWorkspaceGroup +} from './native-chat-resume-on-restart-grouping' + +type WorkspaceFallback = { name: string; parentPath: string } +export type WorkspaceFallbacks = ReadonlyMap + +export function deriveFallbacks(repoGroup: ResumeRepoGroup): WorkspaceFallbacks { + const fallbackDisplays = new Map() + if (repoGroup.repoId === null) { + const countByName = new Map() + for (const group of repoGroup.workspaces) { + const name = getWorktreePathBasenameFromId(group.workspaceId) || group.workspaceId + const path = splitWorktreeIdForFilesystem(group.workspaceId)?.worktreePath + fallbackDisplays.set(group, { name, parentPath: path ? dirname(path) : '' }) + countByName.set(name, (countByName.get(name) ?? 0) + 1) + } + for (const display of fallbackDisplays.values()) { + if (countByName.get(display.name) === 1) { + display.parentPath = '' + } + } + } + return fallbackDisplays +} + +export function resolveNames( + worktree: Parameters[0] | undefined, + fallback: WorkspaceFallback | undefined, + workspaceId: string +): { name: string; controlName: string } { + const name = + (worktree && resolveWorktreeDisplayName(worktree)) || + fallback?.name || + getWorktreePathBasenameFromId(workspaceId) || + workspaceId + return { + name, + controlName: !worktree && fallback?.parentPath ? `${name} (${fallback.parentPath})` : name + } +}