mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
2ae0d67f63
commit
95b037c2e3
13 changed files with 715 additions and 224 deletions
|
|
@ -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>
|
||||
|
|
|
|||
54
apps/desktop/src/app/chat/sidebar/load-more-row.test.tsx
Normal file
54
apps/desktop/src/app/chat/sidebar/load-more-row.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
87
apps/desktop/src/app/chat/sidebar/project-dialog.test.tsx
Normal file
87
apps/desktop/src/app/chat/sidebar/project-dialog.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
145
apps/desktop/src/app/chat/sidebar/session-row.test.tsx
Normal file
145
apps/desktop/src/app/chat/sidebar/session-row.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue