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