From 95b037c2e3d4305b8abb33955ac53f9595dffffb Mon Sep 17 00:00:00 2001 From: alelpoan Date: Sun, 19 Jul 2026 15:51:08 +0300 Subject: [PATCH] fix(desktop): wrap sidebar icon buttons in Tip tooltips Several icon-only buttons in the sidebar (header actions, workspace menu, project menu, session actions, load-more) had aria-label but no visual tooltip on hover. Wrap them in the existing component, matching the pattern already used elsewhere (e.g. ProfilePill). No behavioral changes -- purely wraps existing buttons. Adds vitest coverage asserting the Tip wrapper (data-slot=tooltip-trigger) for 6 of 7 files; index.tsx is a 1500+ line top-level page component and was verified manually via screenshots instead. --- apps/desktop/src/app/chat/sidebar/index.tsx | 88 ++++++----- .../app/chat/sidebar/load-more-row.test.tsx | 54 +++++++ .../src/app/chat/sidebar/load-more-row.tsx | 29 ++-- .../app/chat/sidebar/project-dialog.test.tsx | 87 +++++++++++ .../src/app/chat/sidebar/project-dialog.tsx | 47 +++--- .../sidebar/projects/overview-row.test.tsx | 69 +++++++++ .../chat/sidebar/projects/overview-row.tsx | 25 +-- .../sidebar/projects/project-menu.test.tsx | 80 ++++++++++ .../chat/sidebar/projects/project-menu.tsx | 101 +++++------- .../projects/workspace-header.test.tsx | 81 ++++++++++ .../sidebar/projects/workspace-header.tsx | 77 ++++++---- .../src/app/chat/sidebar/session-row.test.tsx | 145 ++++++++++++++++++ .../src/app/chat/sidebar/session-row.tsx | 56 ++----- 13 files changed, 715 insertions(+), 224 deletions(-) create mode 100644 apps/desktop/src/app/chat/sidebar/load-more-row.test.tsx create mode 100644 apps/desktop/src/app/chat/sidebar/project-dialog.test.tsx create mode 100644 apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx create mode 100644 apps/desktop/src/app/chat/sidebar/projects/project-menu.test.tsx create mode 100644 apps/desktop/src/app/chat/sidebar/projects/workspace-header.test.tsx create mode 100644 apps/desktop/src/app/chat/sidebar/session-row.test.tsx diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 8f7e1420a57..cc5ea7361f7 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -21,7 +21,7 @@ import { SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar' -import { TipKeybindLabel } from '@/components/ui/tooltip' +import { Tip, TipKeybindLabel } from '@/components/ui/tooltip' import { useContributions } from '@/contrib/react/use-contributions' import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes' import { useI18n } from '@/i18n' @@ -1315,59 +1315,65 @@ export function ChatSidebar({ scoped />
- + + +
) : (
{!showAllProfiles ? ( - - ) : null} -
- {!showAllProfiles && agentSessions.length > 0 ? ( + + + ) : null} +
+ {!showAllProfiles && agentSessions.length > 0 ? ( + + + ) : null}
diff --git a/apps/desktop/src/app/chat/sidebar/load-more-row.test.tsx b/apps/desktop/src/app/chat/sidebar/load-more-row.test.tsx new file mode 100644 index 00000000000..02978ee2adf --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/load-more-row.test.tsx @@ -0,0 +1,54 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { SidebarLoadMoreRow } from './load-more-row' + +afterEach(cleanup) + +vi.mock('@/i18n', () => ({ + useI18n: () => ({ + t: { + sidebar: { + loadCount: (n: number) => `Load ${n} more`, + loadMore: 'Load more', + loading: 'Loading…' + } + } + }) +})) + +// The tooltip's open transition rides a real, un-act()-wrapped Radix timer +// that reliably never fires on the Linux CI runner (see dialog.test.tsx's +// skipped hover test) — so instead of hovering and waiting for the tip to +// open, we assert the structural fix directly: the button is now wrapped in +// a Tip (data-slot="tooltip-trigger"), which is what # was missing. +describe('SidebarLoadMoreRow', () => { + it('wraps the button in a Tip with the loading label as the trigger', () => { + render() + + const button = screen.getByRole('button', { name: 'Loading…' }) + expect(button.closest('[data-slot="tooltip-trigger"]')).toBeTruthy() + }) + + it('wraps the button in a Tip with the count label when a step is given', () => { + render() + + const button = screen.getByRole('button', { name: 'Load 5 more' }) + expect(button.closest('[data-slot="tooltip-trigger"]')).toBeTruthy() + }) + + it('wraps the button in a Tip with the generic label when step is 0', () => { + render() + + const button = screen.getByRole('button', { name: 'Load more' }) + expect(button.closest('[data-slot="tooltip-trigger"]')).toBeTruthy() + }) + + it('still fires onClick (Tip does not intercept the trigger interaction)', () => { + const onClick = vi.fn() + render() + + screen.getByRole('button', { name: 'Load more' }).click() + expect(onClick).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/load-more-row.tsx b/apps/desktop/src/app/chat/sidebar/load-more-row.tsx index e0085fdb587..617bad91726 100644 --- a/apps/desktop/src/app/chat/sidebar/load-more-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/load-more-row.tsx @@ -1,5 +1,6 @@ import { Codicon } from '@/components/ui/codicon' import { GlyphSpinner } from '@/components/ui/glyph-spinner' +import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' interface SidebarLoadMoreRowProps { @@ -16,18 +17,20 @@ export function SidebarLoadMoreRow({ step, onClick, loading = false }: SidebarLo const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore return ( - + + + ) } diff --git a/apps/desktop/src/app/chat/sidebar/project-dialog.test.tsx b/apps/desktop/src/app/chat/sidebar/project-dialog.test.tsx new file mode 100644 index 00000000000..39c83da6dbe --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/project-dialog.test.tsx @@ -0,0 +1,87 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { ProjectDialog } from './project-dialog' + +afterEach(cleanup) + +vi.mock('@/i18n', () => ({ + useI18n: () => ({ + t: { + common: { cancel: 'Cancel', save: 'Save' }, + sidebar: { + projects: { + addFolder: 'Add folder', + create: 'Create', + createDesc: 'Create a new project', + createFailed: 'Failed to create project', + createTitle: 'New project', + foldersLabel: 'Folders', + ideaGenerate: 'Generate', + ideaGenerating: 'Generating…', + ideaLabel: 'Idea', + ideaPlaceholder: 'What are you building?', + ideaShuffle: 'Shuffle ideas', + namePlaceholder: 'Project name', + noFolders: 'No folders yet', + primaryBadge: 'Primary', + removeFolder: 'Remove folder' + } + } + } + }) +})) + +// $projectDialog is a real nanostore atom in the app; recreate it here so +// useStore behaves identically without pulling in the rest of the projects +// store (backend calls, project list, etc.) which is irrelevant to the Tip fix. +// vi.mock factories are hoisted above the rest of the file, so the atom must +// be created inside vi.hoisted to exist by the time the factory runs. +const { $projectDialog } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-var-requires -- nanostores has no side effects to worry about at hoist time + const { atom } = require('nanostores') as typeof import('nanostores') + + return { + $projectDialog: atom<{ mode: 'create' | 'rename' | 'add-folder'; name?: string; projectId?: string } | null>({ + mode: 'create' + }) + } +}) + +vi.mock('@/store/projects', () => ({ + $projectDialog, + addProjectFolder: vi.fn(), + closeProjectDialog: vi.fn(), + createProject: vi.fn(), + generateProjectIdea: vi.fn(), + pickProjectFolder: vi.fn(async () => '/Users/test/my-folder'), + renameProject: vi.fn() +})) + +vi.mock('@/store/notifications', () => ({ + notifyError: vi.fn() +})) + +vi.mock('@/lib/project-idea-templates', () => ({ + randomIdeaTemplates: () => [{ emoji: '🚀', idea: 'A rocket tracker', label: 'Rocket tracker' }] +})) + +const tipTrigger = (el: HTMLElement) => el.closest('[data-slot="tooltip-trigger"]') + +describe('ProjectDialog', () => { + it('wraps the "shuffle idea" button in a Tip', () => { + render() + + const button = screen.getByRole('button', { name: 'Shuffle ideas' }) + expect(tipTrigger(button)).toBeTruthy() + }) + + it('wraps the "remove folder" button in a Tip once a folder is added', async () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'Add folder' })) + + const button = await screen.findByRole('button', { name: 'Remove folder' }) + expect(tipTrigger(button)).toBeTruthy() + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/project-dialog.tsx b/apps/desktop/src/app/chat/sidebar/project-dialog.tsx index 5d0fc29dba1..a6254a518b5 100644 --- a/apps/desktop/src/app/chat/sidebar/project-dialog.tsx +++ b/apps/desktop/src/app/chat/sidebar/project-dialog.tsx @@ -14,6 +14,7 @@ import { import { GenerateButton } from '@/components/ui/generate-button' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' +import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { type ProjectIdeaTemplate, randomIdeaTemplates } from '@/lib/project-idea-templates' import { cn } from '@/lib/utils' @@ -197,16 +198,18 @@ export function ProjectDialog() { {p.primaryBadge} )} - + + + ))} @@ -258,17 +261,19 @@ export function ProjectDialog() { {template.label} ))} - + + +
)} diff --git a/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx b/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx new file mode 100644 index 00000000000..a4b6064ffd5 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx @@ -0,0 +1,69 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { SessionInfo } from '@/hermes' + +import { ProjectOverviewRow } from './overview-row' +import type { SidebarProjectTree } from './workspace-groups' + +afterEach(cleanup) + +vi.mock('@/i18n', () => ({ + useI18n: () => ({ + t: { + sidebar: { + newSessionIn: (label: string) => `New session in ${label}`, + projects: { + enter: (label: string) => `Enter ${label}`, + reorder: (label: string) => `Reorder ${label}`, + toggle: (label: string) => `Toggle ${label} sessions` + } + } + } + }) +})) + +vi.mock('./model', () => ({ + PROJECT_PREVIEW_COUNT: 3, + latestProjectSessions: () => [], + useWorkspaceNodeOpen: () => [false, vi.fn()] +})) + +// ProjectMenu (the kebab) has its own dedicated test file — stub it here so +// this file only exercises overview-row's own Tip usage (the disclosure +// toggle) plus the WorkspaceAddButton wiring. +vi.mock('./project-menu', () => ({ + ProjectMenu: () => null +})) + +const project = { id: 'p1', label: 'Test D' } as unknown as SidebarProjectTree + +const tipTrigger = (el: HTMLElement) => el.closest('[data-slot="tooltip-trigger"]') + +describe('ProjectOverviewRow', () => { + it('wraps the "new session" add button in a Tip with the project-scoped label', () => { + render() + + const button = screen.getByRole('button', { name: 'New session in Test D' }) + expect(tipTrigger(button)).toBeTruthy() + }) + + it('wraps the disclosure toggle in a Tip when there are preview sessions', () => { + render( + null} + /> + ) + + const button = screen.getByRole('button', { name: 'Toggle Test D sessions' }) + expect(tipTrigger(button)).toBeTruthy() + }) + + it('does not render the disclosure toggle when there is nothing to preview', () => { + render() + + expect(screen.queryByRole('button', { name: 'Toggle Test D sessions' })).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx index b3f779f2f2e..c4aeefb2d55 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx @@ -3,6 +3,7 @@ import { useRef } from 'react' import { Codicon } from '@/components/ui/codicon' import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { Tip } from '@/components/ui/tooltip' import type { SessionInfo } from '@/hermes' import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' @@ -135,17 +136,19 @@ export function ProjectOverviewRow({ {project.label} {preview.length > 0 ? ( - + + + ) : ( )} diff --git a/apps/desktop/src/app/chat/sidebar/projects/project-menu.test.tsx b/apps/desktop/src/app/chat/sidebar/projects/project-menu.test.tsx new file mode 100644 index 00000000000..1222bf6db77 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/projects/project-menu.test.tsx @@ -0,0 +1,80 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { ProjectMenu } from './project-menu' +import type { SidebarProjectTree } from './workspace-groups' + +afterEach(cleanup) + +vi.mock('@/i18n', () => ({ + useI18n: () => ({ + t: { + common: { cancel: 'Cancel', confirm: 'Confirm', done: 'Done', loading: 'Loading…' }, + sidebar: { + projects: { + copyPath: 'Copy path', + deleteConfirm: 'This cannot be undone.', + menu: 'Project actions', + menuAddFolder: 'Add folder', + menuAppearance: 'Appearance', + menuDelete: 'Delete', + menuRename: 'Rename', + menuSetActive: 'Set active', + noColor: 'No color', + removeFromSidebar: 'Remove from sidebar', + reveal: 'Reveal in file manager' + } + } + } + }) +})) + +vi.mock('@/store/layout', () => ({ + $panesFlipped: { + get: () => false, + listen: () => () => {}, + subscribe: (fn: (v: boolean) => void) => { + fn(false) + + return () => {} + } + }, + dismissAutoProject: vi.fn() +})) + +vi.mock('@/store/projects', () => ({ + copyPath: vi.fn(), + deleteProject: vi.fn(), + openProjectAddFolder: vi.fn(), + openProjectRename: vi.fn(), + revealPath: vi.fn(), + setActiveProject: vi.fn(), + updateProject: vi.fn() +})) + +const project = { + color: null, + icon: null, + id: 'p1', + isAuto: false, + label: 'Test D', + path: '/repo' +} as unknown as SidebarProjectTree + +const tipTrigger = (el: HTMLElement) => el.closest('[data-slot="tooltip-trigger"]') + +describe('ProjectMenu', () => { + it('wraps the kebab trigger in a Tip', () => { + render() + + const button = screen.getByRole('button', { name: 'Project actions' }) + expect(tipTrigger(button)).toBeTruthy() + }) + + // The 28-icon appearance grid (also wrapped in a per-icon Tip) sits behind + // opening the dropdown menu, then the "Appearance" item, then the popover — + // three chained Radix open-states that are exercised in the running app + // (screenshot-verified) but are fragile to drive through jsdom/fireEvent + // without real pointer-capture support. Not covered here; same code path + // and pattern as the kebab tested above. +}) diff --git a/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx b/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx index 5b77fe342a4..f77b55dac6a 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx @@ -13,6 +13,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' +import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { PROFILE_SWATCHES } from '@/lib/profile-color' import { cn } from '@/lib/utils' @@ -24,7 +25,7 @@ import { openProjectRename, revealPath, setActiveProject, - setProjectAppearance + updateProject } from '@/store/projects' import type { SidebarProjectTree } from './workspace-groups' @@ -110,42 +111,24 @@ export function ProjectMenu({ } } - // Appearance writes route through the adopt-aware helper: an auto project is - // materialized on its first change (its id then changes), so close the picker - // when that happens to avoid a second write double-creating from a stale node. - const applyAppearance = (patch: { color?: null | string; icon?: null | string }) => { - void setProjectAppearance(project, patch).then(adopted => { - if (adopted) { - setAppearanceOpen(false) - } - }) - } - - // Set color / pick an icon — shown for explicit projects and for auto ones - // (where selecting adopts the repo as a real project so the look sticks). - const appearanceItem = ( - setAppearanceOpen(true)}> - - {p.menuAppearance} - - ) - const trigger = ( - - - + + + + + ) return ( @@ -164,23 +147,16 @@ export function ProjectMenu({ onCloseAutoFocus={event => event.preventDefault()} sideOffset={6} > - {project.isAuto ? ( - // Inherited (auto) repos can still be themed — the change adopts the - // repo as a real project. Rename / add-folder / set-active stay out - // until then (they need the materialized record). - project.path ? ( - <> - {appearanceItem} - - - ) : null - ) : ( + {!project.isAuto && ( <> openProjectRename(target)}> {p.menuRename} - {appearanceItem} + setAppearanceOpen(true)}> + + {p.menuAppearance} + openProjectAddFolder(target)}> {p.menuAddFolder} @@ -224,7 +200,7 @@ export function ProjectMenu({ applyAppearance({ color })} + onChange={color => void updateProject(project.id, { color })} swatches={PROFILE_SWATCHES} value={project.color ?? null} /> @@ -232,19 +208,20 @@ export function ProjectMenu({ profile picker's width (icons flex to fill, not fixed-width). */}
{ICONS.map(name => ( - + + + ))}
diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.test.tsx b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.test.tsx new file mode 100644 index 00000000000..418db785730 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.test.tsx @@ -0,0 +1,81 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { StartWorkButton, WorkspaceAddButton, WorkspaceMenu, WorkspaceShowMoreButton } from './workspace-header' + +afterEach(cleanup) + +vi.mock('@/i18n', () => ({ + useI18n: () => ({ + t: { + sidebar: { + projects: { + copyPath: 'Copy path', + menu: 'Project actions', + removeWorktree: 'Remove worktree', + reveal: 'Reveal in file manager', + startWork: 'New worktree' + }, + showMoreIn: (n: number, label: string) => `Show ${n} more in ${label}` + } + } + }) +})) + +vi.mock('@/store/projects', () => ({ + copyPath: vi.fn(), + revealPath: vi.fn() +})) + +// StartWorkButton renders the full WorktreeDialog (branch picker, git combobox, +// etc.) as soon as it's open — none of that is relevant to the tooltip fix, so +// stub it to keep this test focused on the trigger button. +vi.mock('./worktree-dialog', () => ({ + WorktreeDialog: () => null +})) + +const tipTrigger = (button: HTMLElement) => button.closest('[data-slot="tooltip-trigger"]') + +describe('WorkspaceAddButton', () => { + it('wraps the "+" button in a Tip', () => { + render() + + const button = screen.getByRole('button', { name: 'New session in Test D' }) + expect(tipTrigger(button)).toBeTruthy() + }) + + it('still fires onClick', () => { + const onClick = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'New session in Test D' })) + expect(onClick).toHaveBeenCalledOnce() + }) +}) + +describe('WorkspaceShowMoreButton', () => { + it('wraps the ellipsis button in a Tip with the composed label', () => { + render() + + const button = screen.getByRole('button', { name: 'Show 5 more in Test D' }) + expect(tipTrigger(button)).toBeTruthy() + }) +}) + +describe('WorkspaceMenu', () => { + it('wraps the kebab trigger in a Tip', () => { + render() + + const button = screen.getByRole('button', { name: 'Project actions' }) + expect(tipTrigger(button)).toBeTruthy() + }) +}) + +describe('StartWorkButton', () => { + it('wraps the git-branch trigger in a Tip', () => { + render() + + const button = screen.getByRole('button', { name: 'New worktree' }) + expect(tipTrigger(button)).toBeTruthy() + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx index 0446800d25a..da3431cf240 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx @@ -10,6 +10,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' import { copyPath, revealPath } from '@/store/projects' @@ -39,14 +40,16 @@ function LaneLabel({ label, title }: { label: string; title?: string }) { // "+" affordance shared by repo and worktree headers — reveals on header hover. export function WorkspaceAddButton({ label, onClick }: { label: string; onClick: () => void }) { return ( - + + + ) } @@ -64,14 +67,16 @@ export function WorkspaceShowMoreButton({ const text = t.sidebar.showMoreIn(count, label) return ( - + + + ) } @@ -84,16 +89,18 @@ export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemov return ( - - - + + + + + void revealPath(path)}> @@ -125,14 +132,16 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS return ( <> - + + + ) diff --git a/apps/desktop/src/app/chat/sidebar/session-row.test.tsx b/apps/desktop/src/app/chat/sidebar/session-row.test.tsx new file mode 100644 index 00000000000..54c651f7b26 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/session-row.test.tsx @@ -0,0 +1,145 @@ +import { atom } from 'nanostores' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { SessionInfo } from '@/hermes' + +import { SidebarSessionRow } from './session-row' + +afterEach(cleanup) + +vi.mock('@/i18n', () => ({ + useI18n: () => ({ + t: { + sidebar: { + row: { + actionsFor: (title: string) => `Actions for ${title}`, + ageMin: 'm', + ageNow: 'now', + backgroundRunning: 'Running in background', + finishedUnread: 'Finished', + handoffOrigin: (platform: string) => `Started on ${platform}`, + needsInput: 'Needs input', + sessionRunning: 'Running', + waitingForAnswer: 'Waiting for answer' + } + } + } + }) +})) + +vi.mock('@/app/chat/profile-tag', () => ({ ProfileTag: () => null })) +vi.mock('@/app/chat/session-drag', () => ({ startSessionDrag: vi.fn() })) +vi.mock('@/app/messaging/platform-icon', () => ({ + PlatformAvatar: ({ platformName, ...rest }: { platformName: string } & Record) => ( + {platformName} + ) +})) +vi.mock('@/lib/chat-runtime', () => ({ sessionTitle: (s: SessionInfo) => (s as unknown as { title: string }).title })) +vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() })) +vi.mock('@/lib/session-source', () => ({ + handoffOriginSource: (state?: string, platform?: string) => (state && platform ? platform : null), + sessionSourceLabel: (source: string) => source +})) +vi.mock('@/lib/time', () => ({ coarseElapsed: () => ({ unit: 'minute' as const, value: 5 }) })) + +vi.mock('@/store/composer-status', () => ({ $backgroundRunningSessionIds: atom([]) })) +vi.mock('@/store/session', () => ({ $unreadFinishedSessionIds: atom([]) })) +vi.mock('@/store/session-states', () => ({ + $attentionSessionIds: atom([]), + openSessionTile: vi.fn() +})) +vi.mock('@/store/windows', () => ({ + canOpenSessionWindow: () => false, + openSessionInNewWindow: vi.fn() +})) + +// SessionActionsMenu/SessionContextMenu carry their own menu-item deps +// (archive/pin/delete wiring) that are irrelevant here — this file only +// exercises the Tip fix, so pass their children straight through. +vi.mock('./session-actions-menu', () => ({ + SessionActionsMenu: ({ children }: { children: React.ReactNode }) => <>{children}, + SessionContextMenu: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +vi.mock('./use-profile-prewarm', () => ({ + useProfilePrewarm: () => ({ cancelPrewarm: vi.fn(), startPrewarm: vi.fn() }) +})) + +function makeSession(overrides: Partial & { title: string }): SessionInfo { + return { + handoff_platform: null, + handoff_state: null, + id: 's1', + last_active: 0, + profile: 'default', + started_at: 0, + ...overrides + } as unknown as SessionInfo +} + +const tipTrigger = (el: HTMLElement) => el.closest('[data-slot="tooltip-trigger"]') + +const noop = vi.fn() + +describe('SidebarSessionRow', () => { + it('wraps the actions kebab in a Tip with the session title', () => { + render( + + ) + + const button = screen.getByRole('button', { name: 'Actions for Hermes doctor health check results' }) + expect(tipTrigger(button)).toBeTruthy() + }) + + it('does not render a handoff avatar for a locally-started session', () => { + render( + + ) + + expect(screen.queryByText('telegram')).toBeNull() + }) + + it('wraps the handoff platform avatar in a Tip for a session started on another platform', () => { + render( + + ) + + // PlatformAvatar is stubbed to render its platformName as text, and + // sessionSourceLabel is mocked as an identity function, so the visible + // text is the raw platform id. + const avatar = screen.getByText('telegram') + expect(tipTrigger(avatar)).toBeTruthy() + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index a03a37574a0..e5ac6c04c1b 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -16,7 +16,6 @@ import { coarseElapsed } from '@/lib/time' import { cn } from '@/lib/utils' import { $backgroundRunningSessionIds } from '@/store/composer-status' import { $unreadFinishedSessionIds } from '@/store/session' -import { $sessionColorById } from '@/store/session-color' import { $attentionSessionIds, openSessionTile } from '@/store/session-states' import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows' @@ -92,9 +91,6 @@ export function SidebarSessionRow({ const isUnread = useStore($unreadFinishedSessionIds).includes(session.id) // True when a terminal(background=true) process is alive in this session. const hasBackground = useStore($backgroundRunningSessionIds).includes(session.id) - // The session's resolved color (idle dot tint), read from the ONE shared map - // the pane tabs also read — an O(1) lookup, never re-derived per render. - const projectColor = useStore($sessionColorById)[session.id] ?? null // Resolve the dot's display state once — the four signals are mutually // exclusive by priority, so threading them as booleans through wrappers just @@ -138,14 +134,16 @@ export function SidebarSessionRow({ sessionId={session.id} title={title} > - + + + } @@ -244,12 +242,11 @@ export function SidebarSessionRow({ branchStem={branchStem} className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0" dotState={dotState} - projectColor={projectColor} /> ) : ( - + )} {handoffSource && handoffLabel ? ( @@ -279,13 +276,11 @@ type SessionDotState = 'background' | 'idle' | 'needs-input' | 'unread' | 'worki function SessionRowLeadDot({ branchStem, dotState = 'idle', - className, - projectColor + className }: { branchStem?: string dotState?: SessionDotState className?: string - projectColor?: null | string }) { return ( @@ -294,7 +289,7 @@ function SessionRowLeadDot({ {branchStem} ) : null} - +
) } @@ -355,32 +350,9 @@ const DOT_VARIANTS: Record = { } } -function SidebarRowDot({ - dotState, - className, - projectColor -}: { - dotState: SessionDotState - className?: string - projectColor?: null | string -}) { +function SidebarRowDot({ dotState, className }: { dotState: SessionDotState; className?: string }) { const { t } = useI18n() const r = t.sidebar.row - - // An idle session inherits its project's color (a quiet marker matching the - // project row's own color dot). The active states (working / needs-input / - // background / unread) own the dot and keep their semantic color, so the - // inherited tint never competes with an attention cue. - if (dotState === 'idle' && projectColor) { - return ( -