diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx
index 8add602e8c7f..5b3f2b4b30c2 100644
--- a/apps/desktop/src/app/chat/sidebar/index.tsx
+++ b/apps/desktop/src/app/chat/sidebar/index.tsx
@@ -21,7 +21,7 @@ import {
SidebarMenuButton,
SidebarMenuItem
} from '@/components/ui/sidebar'
-import { TipKeybindLabel } from '@/components/ui/tooltip'
+import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
import { useContributions } from '@/contrib/react/use-contributions'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { useI18n } from '@/i18n'
@@ -1315,59 +1315,65 @@ export function ChatSidebar({
scoped
/>
-
+
+
+
) : (
{!showAllProfiles ? (
-
- ) : null}
-
- {!showAllProfiles && agentSessions.length > 0 ? (
+
+
+ ) : null}
+
+ {!showAllProfiles && agentSessions.length > 0 ? (
+
+
+
) : null}
diff --git a/apps/desktop/src/app/chat/sidebar/load-more-row.test.tsx b/apps/desktop/src/app/chat/sidebar/load-more-row.test.tsx
new file mode 100644
index 000000000000..02978ee2adf2
--- /dev/null
+++ b/apps/desktop/src/app/chat/sidebar/load-more-row.test.tsx
@@ -0,0 +1,54 @@
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { SidebarLoadMoreRow } from './load-more-row'
+
+afterEach(cleanup)
+
+vi.mock('@/i18n', () => ({
+ useI18n: () => ({
+ t: {
+ sidebar: {
+ loadCount: (n: number) => `Load ${n} more`,
+ loadMore: 'Load more',
+ loading: 'Loading…'
+ }
+ }
+ })
+}))
+
+// The tooltip's open transition rides a real, un-act()-wrapped Radix timer
+// that reliably never fires on the Linux CI runner (see dialog.test.tsx's
+// skipped hover test) — so instead of hovering and waiting for the tip to
+// open, we assert the structural fix directly: the button is now wrapped in
+// a Tip (data-slot="tooltip-trigger"), which is what #
was missing.
+describe('SidebarLoadMoreRow', () => {
+ it('wraps the button in a Tip with the loading label as the trigger', () => {
+ render()
+
+ const button = screen.getByRole('button', { name: 'Loading…' })
+ expect(button.closest('[data-slot="tooltip-trigger"]')).toBeTruthy()
+ })
+
+ it('wraps the button in a Tip with the count label when a step is given', () => {
+ render()
+
+ const button = screen.getByRole('button', { name: 'Load 5 more' })
+ expect(button.closest('[data-slot="tooltip-trigger"]')).toBeTruthy()
+ })
+
+ it('wraps the button in a Tip with the generic label when step is 0', () => {
+ render()
+
+ const button = screen.getByRole('button', { name: 'Load more' })
+ expect(button.closest('[data-slot="tooltip-trigger"]')).toBeTruthy()
+ })
+
+ it('still fires onClick (Tip does not intercept the trigger interaction)', () => {
+ const onClick = vi.fn()
+ render()
+
+ screen.getByRole('button', { name: 'Load more' }).click()
+ expect(onClick).toHaveBeenCalledOnce()
+ })
+})
diff --git a/apps/desktop/src/app/chat/sidebar/load-more-row.tsx b/apps/desktop/src/app/chat/sidebar/load-more-row.tsx
index e0085fdb5879..617bad917265 100644
--- a/apps/desktop/src/app/chat/sidebar/load-more-row.tsx
+++ b/apps/desktop/src/app/chat/sidebar/load-more-row.tsx
@@ -1,5 +1,6 @@
import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
+import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
interface SidebarLoadMoreRowProps {
@@ -16,18 +17,20 @@ export function SidebarLoadMoreRow({ step, onClick, loading = false }: SidebarLo
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
return (
-
+
+
+
)
}
diff --git a/apps/desktop/src/app/chat/sidebar/project-dialog.test.tsx b/apps/desktop/src/app/chat/sidebar/project-dialog.test.tsx
new file mode 100644
index 000000000000..17bc5dd9c7bb
--- /dev/null
+++ b/apps/desktop/src/app/chat/sidebar/project-dialog.test.tsx
@@ -0,0 +1,87 @@
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import 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()
+
+ const button = screen.getByRole('button', { name: 'Shuffle ideas' })
+ expect(tipTrigger(button)).toBeTruthy()
+ })
+
+ it('wraps the "remove folder" button in a Tip once a folder is added', async () => {
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Add folder' }))
+
+ const button = await screen.findByRole('button', { name: 'Remove folder' })
+ expect(tipTrigger(button)).toBeTruthy()
+ })
+})
diff --git a/apps/desktop/src/app/chat/sidebar/project-dialog.tsx b/apps/desktop/src/app/chat/sidebar/project-dialog.tsx
index 5d0fc29dba11..a6254a518b58 100644
--- a/apps/desktop/src/app/chat/sidebar/project-dialog.tsx
+++ b/apps/desktop/src/app/chat/sidebar/project-dialog.tsx
@@ -14,6 +14,7 @@ import {
import { GenerateButton } from '@/components/ui/generate-button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
+import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { type ProjectIdeaTemplate, randomIdeaTemplates } from '@/lib/project-idea-templates'
import { cn } from '@/lib/utils'
@@ -197,16 +198,18 @@ export function ProjectDialog() {
{p.primaryBadge}
)}
-
+
+
+
))}
@@ -258,17 +261,19 @@ export function ProjectDialog() {
{template.label}
))}
-
+
+
+
)}
diff --git a/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx b/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx
new file mode 100644
index 000000000000..a4b6064ffd5b
--- /dev/null
+++ b/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx
@@ -0,0 +1,69 @@
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import type { SessionInfo } from '@/hermes'
+
+import { ProjectOverviewRow } from './overview-row'
+import type { SidebarProjectTree } from './workspace-groups'
+
+afterEach(cleanup)
+
+vi.mock('@/i18n', () => ({
+ useI18n: () => ({
+ t: {
+ sidebar: {
+ newSessionIn: (label: string) => `New session in ${label}`,
+ projects: {
+ enter: (label: string) => `Enter ${label}`,
+ reorder: (label: string) => `Reorder ${label}`,
+ toggle: (label: string) => `Toggle ${label} sessions`
+ }
+ }
+ }
+ })
+}))
+
+vi.mock('./model', () => ({
+ PROJECT_PREVIEW_COUNT: 3,
+ latestProjectSessions: () => [],
+ useWorkspaceNodeOpen: () => [false, vi.fn()]
+}))
+
+// ProjectMenu (the kebab) has its own dedicated test file — stub it here so
+// this file only exercises overview-row's own Tip usage (the disclosure
+// toggle) plus the WorkspaceAddButton wiring.
+vi.mock('./project-menu', () => ({
+ ProjectMenu: () => null
+}))
+
+const project = { id: 'p1', label: 'Test D' } as unknown as SidebarProjectTree
+
+const tipTrigger = (el: HTMLElement) => el.closest('[data-slot="tooltip-trigger"]')
+
+describe('ProjectOverviewRow', () => {
+ it('wraps the "new session" add button in a Tip with the project-scoped label', () => {
+ render()
+
+ const button = screen.getByRole('button', { name: 'New session in Test D' })
+ expect(tipTrigger(button)).toBeTruthy()
+ })
+
+ it('wraps the disclosure toggle in a Tip when there are preview sessions', () => {
+ render(
+ null}
+ />
+ )
+
+ const button = screen.getByRole('button', { name: 'Toggle Test D sessions' })
+ expect(tipTrigger(button)).toBeTruthy()
+ })
+
+ it('does not render the disclosure toggle when there is nothing to preview', () => {
+ render()
+
+ expect(screen.queryByRole('button', { name: 'Toggle Test D sessions' })).toBeNull()
+ })
+})
diff --git a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx
index b3f779f2f2e3..c4aeefb2d55b 100644
--- a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx
+++ b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx
@@ -3,6 +3,7 @@ import { useRef } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
+import { Tip } from '@/components/ui/tooltip'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
@@ -135,17 +136,19 @@ export function ProjectOverviewRow({
{project.label}
{preview.length > 0 ? (
-
+
+
+
) : (
)}
diff --git a/apps/desktop/src/app/chat/sidebar/projects/project-menu.test.tsx b/apps/desktop/src/app/chat/sidebar/projects/project-menu.test.tsx
new file mode 100644
index 000000000000..9ceb3b7be385
--- /dev/null
+++ b/apps/desktop/src/app/chat/sidebar/projects/project-menu.test.tsx
@@ -0,0 +1,132 @@
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
+
+import { ProjectMenu } from './project-menu'
+import type { SidebarProjectTree } from './workspace-groups'
+
+afterEach(cleanup)
+
+// jsdom doesn't implement ResizeObserver; Radix's PopoverContent/Arrow use it
+// (via @radix-ui/react-use-size) to measure the arrow once the popover is
+// actually mounted. The kebab-only test above never opens a Popover, so it
+// doesn't need this — only the appearance-popover test below does.
+beforeAll(() => {
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+ )
+})
+
+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(),
+ setProjectAppearance: vi.fn().mockResolvedValue(false)
+}))
+
+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"]')
+
+const openTriggerMenu = (trigger: HTMLElement) => {
+ // Radix's dropdown trigger opens on pointerdown (a synthetic 'click' fireEvent
+ // alone won't do it), so fire the full mouse sequence a real click produces —
+ // same technique as session-actions-menu.test.tsx (#67500).
+ fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse' })
+ fireEvent.pointerUp(trigger, { button: 0, pointerType: 'mouse' })
+ fireEvent.click(trigger)
+}
+
+describe('ProjectMenu', () => {
+ it('wraps the kebab trigger in a Tip', () => {
+ render()
+
+ const button = screen.getByRole('button', { name: 'Project actions' })
+ expect(tipTrigger(button)).toBeTruthy()
+ })
+
+ // #67500 (Gille, second pass): when anchorRef is absent, the trigger used to
+ // be `{trigger}` where `trigger` was
+ // ALREADY wrapped in — so PopoverAnchor's asChild cloned Tip itself
+ // (Tip doesn't forward extra props to its children), and the popover's
+ // real-DOM anchor ref never reached the button. Composing Tip OUTSIDE
+ // PopoverAnchor (Tip > PopoverAnchor > DropdownMenuTrigger > button) fixes
+ // that ref delivery.
+ //
+ // What this test can't verify: jsdom has no layout engine, so the actual
+ // POSITIONING the anchor ref enables isn't observable here — same
+ // limitation already noted above for the icon grid. What it does verify:
+ // the 3-deep asChild chain doesn't regress into the same silent-drop
+ // failure as the original bug (#67500, first pass) — the trigger stays a
+ // real, clickable element that opens the menu and reaches the Appearance
+ // popover end-to-end, for the anchorRef-absent path specifically (the
+ // anchorRef-present path never touches PopoverAnchor and is covered by the
+ // kebab test above).
+ it('opens the appearance popover through the kebab trigger when anchorRef is absent', async () => {
+ render()
+
+ const trigger = screen.getByRole('button', { name: 'Project actions' })
+
+ openTriggerMenu(trigger)
+
+ const appearanceItem = await screen.findByRole('menuitem', { name: 'Appearance' })
+
+ fireEvent.click(appearanceItem)
+
+ // The color-swatch "No color" clear option only renders once the
+ // appearance Popover is actually open — proving the click reached the
+ // real button through the full Tip > PopoverAnchor > DropdownMenuTrigger
+ // chain rather than getting silently dropped on an intermediate wrapper.
+ expect(await screen.findByRole('button', { name: 'No color' })).toBeTruthy()
+ }, 15000)
+})
diff --git a/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx b/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx
index 19d86a11cd62..6eaeaab12a4e 100644
--- a/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx
+++ b/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx
@@ -13,6 +13,7 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
+import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { PROFILE_SWATCHES } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
@@ -128,7 +129,14 @@ export function ProjectMenu({
)
- const trigger = (
+ // The bare trigger button (no Tip, no anchor) — composed with whichever of
+ // Tip / PopoverAnchor apply below, always OUTSIDE the asChild chain that
+ // ends at this button, never wrapping it directly. asChild clones only its
+ // immediate child, so any of these wrappers placed inside another
+ // asChild-consuming component (instead of around it) would have its
+ // injected props silently swallowed by that inner component instead of
+ // reaching the real DOM button (see #67500).
+ const triggerButton = (