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 <Tip> 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.
This commit is contained in:
alelpoan 2026-07-19 15:51:08 +03:00
parent 2ae0d67f63
commit 95b037c2e3
13 changed files with 715 additions and 224 deletions

View file

@ -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
/>
<div className="grid size-6 place-items-center">
<Button
aria-label={s.showProjects}
className={HEADER_NAV_BTN}
onClick={event => {
event.stopPropagation()
exitProjectScope()
}}
size="icon-xs"
variant="ghost"
>
<Codicon name="list-unordered" size="0.75rem" />
</Button>
<Tip label={s.showProjects}>
<Button
aria-label={s.showProjects}
className={HEADER_NAV_BTN}
onClick={event => {
event.stopPropagation()
exitProjectScope()
}}
size="icon-xs"
variant="ghost"
>
<Codicon name="list-unordered" size="0.75rem" />
</Button>
</Tip>
</div>
</div>
) : (
<div className="flex shrink-0 items-center gap-0.5">
{!showAllProfiles ? (
<Button
aria-label={agentsGrouped ? s.projects.newButton : s.nav['new-session']}
className={HEADER_ACTION_BTN}
onClick={event => {
event.stopPropagation()
if (agentsGrouped) {
openProjectCreate()
} else {
onNewSessionInWorkspace(null)
}
}}
size="icon-xs"
variant="ghost"
>
<Codicon name="add" size="0.75rem" />
</Button>
) : null}
<div className="grid size-6 place-items-center">
{!showAllProfiles && agentSessions.length > 0 ? (
<Tip label={agentsGrouped ? s.projects.newButton : s.nav['new-session']}>
<Button
aria-label={agentsGrouped ? s.showSessions : s.showProjects}
className={cn(
HEADER_NAV_BTN,
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
aria-label={agentsGrouped ? s.projects.newButton : s.nav['new-session']}
className={HEADER_ACTION_BTN}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
setSidebarAgentsGrouped(!agentsGrouped)
if (agentsGrouped) {
openProjectCreate()
} else {
onNewSessionInWorkspace(null)
}
}}
size="icon-xs"
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
<Codicon name="add" size="0.75rem" />
</Button>
</Tip>
) : null}
<div className="grid size-6 place-items-center">
{!showAllProfiles && agentSessions.length > 0 ? (
<Tip label={agentsGrouped ? s.showSessions : s.showProjects}>
<Button
aria-label={agentsGrouped ? s.showSessions : s.showProjects}
className={cn(
HEADER_NAV_BTN,
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
setSidebarAgentsGrouped(!agentsGrouped)
}}
size="icon-xs"
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
</Button>
</Tip>
) : null}
</div>
</div>

View file

@ -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 #<issue> was missing.
describe('SidebarLoadMoreRow', () => {
it('wraps the button in a Tip with the loading label as the trigger', () => {
render(<SidebarLoadMoreRow loading onClick={vi.fn()} step={0} />)
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(<SidebarLoadMoreRow onClick={vi.fn()} step={5} />)
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(<SidebarLoadMoreRow onClick={vi.fn()} step={0} />)
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(<SidebarLoadMoreRow onClick={onClick} step={0} />)
screen.getByRole('button', { name: 'Load more' }).click()
expect(onClick).toHaveBeenCalledOnce()
})
})

View file

@ -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 (
<button
aria-label={label}
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground disabled:cursor-default disabled:opacity-60 disabled:hover:bg-transparent disabled:hover:text-(--ui-text-tertiary)"
disabled={loading}
onClick={onClick}
type="button"
>
{loading ? (
<GlyphSpinner ariaLabel={label} className="text-[0.75rem]" />
) : (
<Codicon name="ellipsis" size="0.75rem" />
)}
</button>
<Tip label={label}>
<button
aria-label={label}
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground disabled:cursor-default disabled:opacity-60 disabled:hover:bg-transparent disabled:hover:text-(--ui-text-tertiary)"
disabled={loading}
onClick={onClick}
type="button"
>
{loading ? (
<GlyphSpinner ariaLabel={label} className="text-[0.75rem]" />
) : (
<Codicon name="ellipsis" size="0.75rem" />
)}
</button>
</Tip>
)
}

View file

@ -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(<ProjectDialog />)
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(<ProjectDialog />)
fireEvent.click(screen.getByRole('button', { name: 'Add folder' }))
const button = await screen.findByRole('button', { name: 'Remove folder' })
expect(tipTrigger(button)).toBeTruthy()
})
})

View file

@ -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}
</span>
)}
<Button
aria-label={p.removeFolder}
className="size-5 shrink-0 text-(--ui-text-quaternary) hover:text-foreground"
onClick={() => setFolders(prev => prev.filter(f => f !== folder))}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.75rem" />
</Button>
<Tip label={p.removeFolder}>
<Button
aria-label={p.removeFolder}
className="size-5 shrink-0 text-(--ui-text-quaternary) hover:text-foreground"
onClick={() => setFolders(prev => prev.filter(f => f !== folder))}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.75rem" />
</Button>
</Tip>
</li>
))}
</ul>
@ -258,17 +261,19 @@ export function ProjectDialog() {
{template.label}
</button>
))}
<Button
aria-label={p.ideaShuffle}
className="size-5 text-(--ui-text-quaternary) hover:text-foreground"
disabled={submitting}
onClick={() => setTemplates(randomIdeaTemplates())}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.75rem" />
</Button>
<Tip label={p.ideaShuffle}>
<Button
aria-label={p.ideaShuffle}
className="size-5 text-(--ui-text-quaternary) hover:text-foreground"
disabled={submitting}
onClick={() => setTemplates(randomIdeaTemplates())}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.75rem" />
</Button>
</Tip>
</div>
</div>
)}

View file

@ -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(<ProjectOverviewRow onNewSession={vi.fn()} project={project} />)
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(
<ProjectOverviewRow
previewSessions={[{ id: 's1' } as unknown as SessionInfo]}
project={project}
renderRows={() => 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(<ProjectOverviewRow project={project} />)
expect(screen.queryByRole('button', { name: 'Toggle Test D sessions' })).toBeNull()
})
})

View file

@ -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}
</SidebarRowLink>
{preview.length > 0 ? (
<button
aria-label={s.projects.toggle(project.label)}
className="flex flex-1 items-center self-stretch bg-transparent p-0"
onClick={toggleOpen}
type="button"
>
<DisclosureCaret
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
</button>
<Tip label={s.projects.toggle(project.label)}>
<button
aria-label={s.projects.toggle(project.label)}
className="flex flex-1 items-center self-stretch bg-transparent p-0"
onClick={toggleOpen}
type="button"
>
<DisclosureCaret
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
</button>
</Tip>
) : (
<span className="flex-1" />
)}

View file

@ -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(<ProjectMenu isActive={false} project={project} />)
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.
})

View file

@ -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 = (
<DropdownMenuItem onSelect={() => setAppearanceOpen(true)}>
<Codicon name="symbol-color" size="0.875rem" />
<span>{p.menuAppearance}</span>
</DropdownMenuItem>
)
const trigger = (
<DropdownMenuTrigger asChild>
<button
aria-label={p.menu}
className={cn(
'grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:opacity-100',
// In the project header reveal on the whole header hover; in overview
// rows reveal on the row hover.
scoped ? 'group-hover/section:opacity-100' : 'group-hover/workspace:opacity-100'
)}
onClick={event => event.stopPropagation()}
type="button"
>
<Codicon name="kebab-vertical" size="0.75rem" />
</button>
</DropdownMenuTrigger>
<Tip label={p.menu}>
<DropdownMenuTrigger asChild>
<button
aria-label={p.menu}
className={cn(
'grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:opacity-100',
// In the project header reveal on the whole header hover; in overview
// rows reveal on the row hover.
scoped ? 'group-hover/section:opacity-100' : 'group-hover/workspace:opacity-100'
)}
onClick={event => event.stopPropagation()}
type="button"
>
<Codicon name="kebab-vertical" size="0.75rem" />
</button>
</DropdownMenuTrigger>
</Tip>
)
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}
<DropdownMenuSeparator />
</>
) : null
) : (
{!project.isAuto && (
<>
<DropdownMenuItem onSelect={() => openProjectRename(target)}>
<Codicon name="edit" size="0.875rem" />
<span>{p.menuRename}</span>
</DropdownMenuItem>
{appearanceItem}
<DropdownMenuItem onSelect={() => setAppearanceOpen(true)}>
<Codicon name="symbol-color" size="0.875rem" />
<span>{p.menuAppearance}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => openProjectAddFolder(target)}>
<Codicon name="new-folder" size="0.875rem" />
<span>{p.menuAddFolder}</span>
@ -224,7 +200,7 @@ export function ProjectMenu({
<ColorSwatches
clearIcon="circle-slash"
clearLabel={p.noColor}
onChange={color => 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). */}
<div className="mt-2 grid grid-cols-6 gap-1.5">
{ICONS.map(name => (
<button
aria-label={name}
className={cn(
'grid aspect-square place-items-center rounded-md text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background)',
project.icon === name && 'bg-(--ui-control-active-background) text-foreground'
)}
key={name}
onClick={() => applyAppearance({ icon: project.icon === name ? null : name })}
style={project.icon === name && project.color ? { color: project.color } : undefined}
type="button"
>
<Codicon name={name} size="0.8125rem" />
</button>
<Tip key={name} label={name}>
<button
aria-label={name}
className={cn(
'grid aspect-square place-items-center rounded-md text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background)',
project.icon === name && 'bg-(--ui-control-active-background) text-foreground'
)}
onClick={() => void updateProject(project.id, { icon: project.icon === name ? null : name })}
style={project.icon === name && project.color ? { color: project.color } : undefined}
type="button"
>
<Codicon name={name} size="0.8125rem" />
</button>
</Tip>
))}
</div>
</PopoverContent>

View file

@ -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(<WorkspaceAddButton label="New session in Test D" onClick={vi.fn()} />)
const button = screen.getByRole('button', { name: 'New session in Test D' })
expect(tipTrigger(button)).toBeTruthy()
})
it('still fires onClick', () => {
const onClick = vi.fn()
render(<WorkspaceAddButton label="New session in Test D" onClick={onClick} />)
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(<WorkspaceShowMoreButton count={5} label="Test D" onClick={vi.fn()} />)
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(<WorkspaceMenu onRemove={vi.fn()} path="/repo/lane" />)
const button = screen.getByRole('button', { name: 'Project actions' })
expect(tipTrigger(button)).toBeTruthy()
})
})
describe('StartWorkButton', () => {
it('wraps the git-branch trigger in a Tip', () => {
render(<StartWorkButton onStarted={vi.fn()} repoPath="/repo" />)
const button = screen.getByRole('button', { name: 'New worktree' })
expect(tipTrigger(button)).toBeTruthy()
})
})

View file

@ -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 (
<button
aria-label={label}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
onClick={onClick}
type="button"
>
<Codicon name="add" size="0.75rem" />
</button>
<Tip label={label}>
<button
aria-label={label}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
onClick={onClick}
type="button"
>
<Codicon name="add" size="0.75rem" />
</button>
</Tip>
)
}
@ -64,14 +67,16 @@ export function WorkspaceShowMoreButton({
const text = t.sidebar.showMoreIn(count, label)
return (
<button
aria-label={text}
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={onClick}
type="button"
>
<Codicon name="ellipsis" size="0.75rem" />
</button>
<Tip label={text}>
<button
aria-label={text}
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={onClick}
type="button"
>
<Codicon name="ellipsis" size="0.75rem" />
</button>
</Tip>
)
}
@ -84,16 +89,18 @@ export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemov
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label={p.menu}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100 data-[state=open]:opacity-100"
onClick={event => event.stopPropagation()}
type="button"
>
<Codicon name="kebab-vertical" size="0.75rem" />
</button>
</DropdownMenuTrigger>
<Tip label={p.menu}>
<DropdownMenuTrigger asChild>
<button
aria-label={p.menu}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100 data-[state=open]:opacity-100"
onClick={event => event.stopPropagation()}
type="button"
>
<Codicon name="kebab-vertical" size="0.75rem" />
</button>
</DropdownMenuTrigger>
</Tip>
<DropdownMenuContent align="end" className="w-48" sideOffset={6}>
<DropdownMenuItem disabled={!path} onSelect={() => void revealPath(path)}>
<Codicon name="folder-opened" size="0.875rem" />
@ -125,14 +132,16 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS
return (
<>
<button
aria-label={p.startWork}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/section:opacity-100 focus-visible:opacity-100"
onClick={() => setOpen(true)}
type="button"
>
<Codicon name="git-branch" size="0.75rem" />
</button>
<Tip label={p.startWork}>
<button
aria-label={p.startWork}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/section:opacity-100 focus-visible:opacity-100"
onClick={() => setOpen(true)}
type="button"
>
<Codicon name="git-branch" size="0.75rem" />
</button>
</Tip>
<WorktreeDialog onOpenChange={setOpen} onStarted={onStarted} open={open} repoPath={repoPath} />
</>
)

View file

@ -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<string, unknown>) => (
<span {...rest}>{platformName}</span>
)
}))
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<string[]>([]) }))
vi.mock('@/store/session', () => ({ $unreadFinishedSessionIds: atom<string[]>([]) }))
vi.mock('@/store/session-states', () => ({
$attentionSessionIds: atom<string[]>([]),
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<SessionInfo> & { 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(
<SidebarSessionRow
isPinned={false}
isSelected={false}
isWorking={false}
onArchive={noop}
onDelete={noop}
onPin={noop}
onResume={noop}
session={makeSession({ title: 'Hermes doctor health check results' })}
/>
)
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(
<SidebarSessionRow
isPinned={false}
isSelected={false}
isWorking={false}
onArchive={noop}
onDelete={noop}
onPin={noop}
onResume={noop}
session={makeSession({ title: 'Local session' })}
/>
)
expect(screen.queryByText('telegram')).toBeNull()
})
it('wraps the handoff platform avatar in a Tip for a session started on another platform', () => {
render(
<SidebarSessionRow
isPinned={false}
isSelected={false}
isWorking={false}
onArchive={noop}
onDelete={noop}
onPin={noop}
onResume={noop}
session={makeSession({
handoff_platform: 'telegram',
handoff_state: 'active',
title: 'Continued from Telegram'
})}
/>
)
// 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()
})
})

View file

@ -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}
>
<Button
aria-label={r.actionsFor(title)}
className="size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
size="icon"
variant="ghost"
>
<Codicon name="kebab-vertical" size="0.875rem" />
</Button>
<Tip label={r.actionsFor(title)}>
<Button
aria-label={r.actionsFor(title)}
className="size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
size="icon"
variant="ghost"
>
<Codicon name="kebab-vertical" size="0.875rem" />
</Button>
</Tip>
</SessionActionsMenu>
</div>
}
@ -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}
/>
</SidebarRowGrab>
) : (
<SidebarRowLead className={needsInput ? 'overflow-visible' : 'overflow-hidden'}>
<SessionRowLeadDot branchStem={branchStem} dotState={dotState} projectColor={projectColor} />
<SessionRowLeadDot branchStem={branchStem} dotState={dotState} />
</SidebarRowLead>
)}
{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 (
<span className={cn('flex items-center gap-0.5', className)}>
@ -294,7 +289,7 @@ function SessionRowLeadDot({
{branchStem}
</span>
) : null}
<SidebarRowDot dotState={dotState} projectColor={projectColor} />
<SidebarRowDot dotState={dotState} />
</span>
)
}
@ -355,32 +350,9 @@ const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
}
}
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 (
<span
aria-hidden="true"
className={cn('size-1 rounded-full', className)}
style={{ backgroundColor: projectColor }}
/>
)
}
const variant = DOT_VARIANTS[dotState]
return (