From 6dafe8c8705fdd62d79b638ea2a1c6a5470925dd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:06:10 -0700 Subject: [PATCH] Fix worktree sort crash when display name is missing (#6993) --- .../src/components/sidebar/WorktreeList.tsx | 4 +-- .../src/components/sidebar/smart-sort.test.ts | 16 +++++++++ .../src/components/sidebar/smart-sort.ts | 34 +++++++++++++++---- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index bb16fbde9..75cab5bcc 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -63,7 +63,7 @@ import type { WorkspaceStatusDefinition } from '../../../../shared/types' import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' -import { buildWorktreeComparator } from './smart-sort' +import { buildWorktreeComparator, compareWorktreeSortLabel } from './smart-sort' import { buildAttentionByWorktree, type SmartClass, @@ -5193,7 +5193,7 @@ const WorktreeList = React.memo(function WorktreeList({ sessionHasHadPty.current = true } else { nonArchivedWorktrees.sort( - (a, b) => b.sortOrder - a.sortOrder || a.displayName.localeCompare(b.displayName) + (a, b) => b.sortOrder - a.sortOrder || compareWorktreeSortLabel(a, b) ) lastAttentionByWorktreeRef.current = null return nonArchivedWorktrees.map((w) => w.id) diff --git a/src/renderer/src/components/sidebar/smart-sort.test.ts b/src/renderer/src/components/sidebar/smart-sort.test.ts index 34092befe..cb61490c9 100644 --- a/src/renderer/src/components/sidebar/smart-sort.test.ts +++ b/src/renderer/src/components/sidebar/smart-sort.test.ts @@ -458,6 +458,22 @@ describe('sortWorktreesSmart — cold start fallback', () => { expect(sorted.map((w) => w.id)).toEqual(['b', 'a']) }) + it('falls back to the path label when a persisted worktree has no displayName', () => { + const missingDisplayName = { + ...makeWorktree({ + id: 'missing-display-name', + path: '/tmp/alpha-path', + sortOrder: 1 + }), + displayName: undefined + } as unknown as Worktree + const named = makeWorktree({ id: 'named', displayName: 'Zulu', sortOrder: 1 }) + + const sorted = sortWorktreesSmart([named, missingDisplayName], {}, repoMap, {}, {}, {}) + + expect(sorted.map((w) => w.id)).toEqual(['missing-display-name', 'named']) + }) + it('treats slept tabs (tab.ptyId without live entry) as cold start', () => { // Why: tab.ptyId is the wake-hint sessionId preserved under sleep — not a // liveness signal. With slept tabs but no live PTYs, sortWorktreesSmart diff --git a/src/renderer/src/components/sidebar/smart-sort.ts b/src/renderer/src/components/sidebar/smart-sort.ts index 4c87740a9..616e23bc0 100644 --- a/src/renderer/src/components/sidebar/smart-sort.ts +++ b/src/renderer/src/components/sidebar/smart-sort.ts @@ -4,6 +4,7 @@ import type { MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status-types' import { tabHasLivePty } from '@/lib/tab-has-live-pty' +import { basename } from '@/lib/path' import { IDLE, buildAttentionByWorktree, type WorktreeAttention } from './smart-attention' export type SortBy = 'name' | 'smart' | 'recent' | 'repo' | 'manual' @@ -40,6 +41,27 @@ export function effectiveRecentActivity(worktree: Worktree, now: number): number return Math.max(lastActivityAt, createdAt + CREATE_GRACE_MS) } +type WorktreeSortLabelInput = Pick + +export function getWorktreeSortLabel(worktree: WorktreeSortLabelInput): string { + const displayName = typeof worktree.displayName === 'string' ? worktree.displayName.trim() : '' + if (displayName) { + return displayName + } + + // Why: persisted or remote worktree state can briefly omit displayName after + // a custom workspace name is removed; sorting must stay render-safe. + const pathLabel = typeof worktree.path === 'string' ? basename(worktree.path).trim() : '' + return pathLabel || worktree.id +} + +export function compareWorktreeSortLabel( + a: WorktreeSortLabelInput, + b: WorktreeSortLabelInput +): number { + return getWorktreeSortLabel(a).localeCompare(getWorktreeSortLabel(b)) +} + /** * Build a comparator for sorting worktrees based on the current sort mode. * @@ -58,7 +80,7 @@ export function buildWorktreeComparator( return (a, b) => { switch (sortBy) { case 'name': - return a.displayName.localeCompare(b.displayName) + return compareWorktreeSortLabel(a, b) case 'smart': { const aw = attentionByWorktree.get(a.id) ?? IDLE const bw = attentionByWorktree.get(b.id) ?? IDLE @@ -70,7 +92,7 @@ export function buildWorktreeComparator( // Why: idle worktrees fall through to recency (and the create-grace // floor for brand-new worktrees) before alphabetical. effectiveRecentActivity(b, now) - effectiveRecentActivity(a, now) || - a.displayName.localeCompare(b.displayName) + compareWorktreeSortLabel(a, b) ) } case 'recent': @@ -87,13 +109,13 @@ export function buildWorktreeComparator( // events) and by meaningful meta edits (comment, isUnread). return ( effectiveRecentActivity(b, now) - effectiveRecentActivity(a, now) || - a.displayName.localeCompare(b.displayName) + compareWorktreeSortLabel(a, b) ) case 'repo': { const ra = repoMap.get(a.repoId)?.displayName ?? '' const rb = repoMap.get(b.repoId)?.displayName ?? '' const cmp = ra.localeCompare(rb) - return cmp !== 0 ? cmp : a.displayName.localeCompare(b.displayName) + return cmp !== 0 ? cmp : compareWorktreeSortLabel(a, b) } case 'manual': // Why fallback to sortOrder: existing users have a persisted smart-sort @@ -101,7 +123,7 @@ export function buildWorktreeComparator( // restored order instead of alphabetizing every legacy workspace. return ( (b.manualOrder ?? b.sortOrder) - (a.manualOrder ?? a.sortOrder) || - a.displayName.localeCompare(b.displayName) + compareWorktreeSortLabel(a, b) ) } } @@ -143,7 +165,7 @@ export function sortWorktreesSmart( // Cold start: use persisted sortOrder snapshot until the agent-status // snapshot lands and a warm sort runs. return [...worktrees].sort( - (a, b) => b.sortOrder - a.sortOrder || a.displayName.localeCompare(b.displayName) + (a, b) => b.sortOrder - a.sortOrder || compareWorktreeSortLabel(a, b) ) }