Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -41,6 +42,7 @@ beforeEach(() => {

afterEach(() => {
act(() => root.unmount())
vi.restoreAllMocks()
container.remove()
useAppStore.setState(useAppStore.getInitialState(), true)
})
Expand Down Expand Up @@ -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', () => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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<unknown, unknown>,
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<HTMLElement>(
'[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 = {
Expand Down
32 changes: 22 additions & 10 deletions src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
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,
parseExecutionHostId,
type ExecutionHostId
} from '../../../shared/execution-host'
import { useAppStore } from '../store'
import * as workspaceDisplay from './native-chat-resume-workspace-display'
import {
useLineageAncestors,
useRepoIdByWorkspace,
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -111,30 +111,33 @@ 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]
const worktree = useWorkspaceWorktree(group.workspaceId, first?.executionHostId)
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 (
<GroupNode
nodeKey={`workspace:${hostId}:${group.workspaceId}`}
depth={depth}
name={name}
name={controlName}
checkboxLabel={translate(
'auto.components.NativeChatResumeOnRestartModal.selectWorkspace',
'Select all chats in {{value0}}',
{ value0: name }
{ value0: controlName }
)}
covered={coveredKeys(resumeWorkspaceCandidates(node), tree)}
tree={tree}
Expand All @@ -147,6 +150,12 @@ function WorkspaceNode({
{branch}
</span>
)}
{!worktree && fallback?.parentPath && (
<ProjectOptionDetail
detail={fallback.parentPath}
className="shrink-2 text-[11px] text-muted-foreground"
/>
)}
</>
}
>
Expand All @@ -157,7 +166,7 @@ function WorkspaceNode({
<ResumeCandidateRow
key={chat.key}
candidate={candidate}
workspaceName={name}
workspaceName={controlName}
listedAt={tree.listedAt}
checked={chat.checked}
disabled={tree.busy}
Expand All @@ -176,6 +185,7 @@ function WorkspaceNode({
hostId={hostId}
depth={depth + 1}
tree={tree}
fallbackDisplays={fallbackDisplays}
/>
))}
</GroupNode>
Expand Down Expand Up @@ -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) => (
Expand All @@ -281,6 +292,7 @@ function MachineContents({
hostId={hostId}
depth={depth}
tree={tree}
fallbackDisplays={fallbackDisplays}
/>
))
) : (
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ResumeWorkspaceGroup, WorkspaceFallback>

export function deriveFallbacks(repoGroup: ResumeRepoGroup): WorkspaceFallbacks {
const fallbackDisplays = new Map<ResumeWorkspaceGroup, WorkspaceFallback>()
if (repoGroup.repoId === null) {
const countByName = new Map<string, number>()
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<typeof resolveWorktreeDisplayName>[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
}
}
Loading