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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 47 additions & 41 deletions apps/desktop/src/app/chat/sidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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>
Expand Down
54 changes: 54 additions & 0 deletions apps/desktop/src/app/chat/sidebar/load-more-row.test.tsx
Original file line number Diff line number Diff line change
@@ -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()
})
})
29 changes: 16 additions & 13 deletions apps/desktop/src/app/chat/sidebar/load-more-row.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 changes: 87 additions & 0 deletions apps/desktop/src/app/chat/sidebar/project-dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type * as Nanostores from 'nanostores'
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(() => {
const { atom } = require('nanostores') as typeof 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()
})
})
47 changes: 26 additions & 21 deletions apps/desktop/src/app/chat/sidebar/project-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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>
)}
Expand Down
Loading
Loading