Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ vi.mock('@/i18n', () => ({
projects: {
enter: (label: string) => `Enter ${label}`,
reorder: (label: string) => `Reorder ${label}`,
toggle: (label: string) => `Toggle ${label} sessions`
toggle: (label: string, open: boolean) => `${open ? 'Show' : 'Hide'} ${label} sessions`
}
}
}
Expand Down Expand Up @@ -60,14 +60,15 @@ describe('ProjectOverviewRow', () => {
/>
)

const button = screen.getByRole('button', { name: 'Toggle Test D sessions' })
// Collapsed by default, so the disclosure offers to show the sessions.
const button = screen.getByRole('button', { name: 'Show 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()
expect(screen.queryByRole('button', { name: 'Show Test D sessions' })).toBeNull()
})

it('drops the "new session" add button on Home, which has no folder to start in', () => {
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,9 @@ export function ProjectOverviewRow({
{project.label}
</SidebarRowLink>
{preview.length > 0 ? (
<Tip label={s.projects.toggle(project.label)}>
<Tip label={s.projects.toggle(project.label, !open)}>
<button
aria-label={s.projects.toggle(project.label)}
aria-label={s.projects.toggle(project.label, !open)}
className="flex flex-1 items-center self-stretch bg-transparent p-0"
onClick={toggleOpen}
type="button"
Expand Down
38 changes: 38 additions & 0 deletions apps/desktop/src/app/command-palette/contrib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ export interface PaletteContribution {
icon?: IconComponent
keywords?: string[]
run: () => void
/**
* Short note after the label — the live state the row acts on. A function
* because contributions register once at boot while that state keeps moving;
* the palette re-reads it on open.
*/
detail?: () => string
/** `state` when running the row CHANGES what `detail` says. */
detailVariant?: 'muted' | 'state'
/** Leave the palette open after running — for rows you may run repeatedly. */
keepOpen?: boolean
}

/** Contributed palette rows, with stable render keys. */
Expand All @@ -26,3 +36,31 @@ export function usePaletteContributions(): Array<PaletteContribution & { key: st
.map(c => ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as PaletteContribution) }))
.filter(item => Boolean(item.label && item.run))
}

/**
* A binary setting as one palette row: `Toggle status bar` trailed by the live
* state. The verb says what the row does, the note says where it stands —
* neither alone is enough to act on.
*
* Rows keep the palette open: flipping a setting is the kind of thing you do
* two or three of in a row, and the note updating in place is the receipt.
*/
export function paletteToggle(
spec: Omit<PaletteContribution, 'detail' | 'detailVariant' | 'keepOpen' | 'run'> & {
get: () => boolean
set: (enabled: boolean) => void
}
) {
const { get, keywords = [], set, ...rest } = spec

const data: PaletteContribution = {
...rest,
detail: () => (get() ? 'on' : 'off'),
detailVariant: 'state',
keepOpen: true,
keywords: [...keywords, 'on', 'off', 'enable', 'disable'],
run: () => set(!get())
}

return { id: data.id, area: PALETTE_AREA, data }
}
141 changes: 93 additions & 48 deletions apps/desktop/src/app/command-palette/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
import { memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'

import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud'
import {
HUD_HEADING,
HUD_ITEM,
HUD_NOTE,
HUD_NOTE_VARIANT,
HUD_POSITION,
HUD_SURFACE,
HUD_TEXT
} from '@/app/floating-hud'
import { setTerminalTakeover } from '@/app/right-sidebar/store'
import { codiconIcon } from '@/components/ui/codicon'
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
Expand Down Expand Up @@ -106,8 +114,10 @@ interface PaletteItem {
active?: boolean
/** Static trailing combo hint for a modifier-variant select (e.g. `mod+enter`). */
comboHint?: string
/** Muted text beside the label — state the row acts on (a version, a count). */
/** Short note beside the label — state the row acts on (a version, a count). */
detail?: string
/** `state` when the row will change what `detail` says (a toggle's on/off). */
detailVariant?: keyof typeof HUD_NOTE_VARIANT
icon: IconComponent
id: string
/** Keep the palette open after running (live-preview pickers like theme/mode). */
Expand Down Expand Up @@ -340,7 +350,9 @@ const PaletteRow = memo(function PaletteRow({
<HighlightMatches query={search.split(/\s+/)} text={item.label} />
)}
</span>
{item.detail && <span className="truncate text-muted-foreground/80">{item.detail}</span>}
{item.detail && (
<span className={cn(HUD_NOTE, HUD_NOTE_VARIANT[item.detailVariant ?? 'muted'])}>{item.detail}</span>
)}
{combo && (
<KbdCombo className={cn('ml-auto', modPreview ? 'opacity-90' : 'opacity-55')} combo={combo} size="sm" />
)}
Expand Down Expand Up @@ -652,8 +664,40 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) {
[t.settings.fieldLabels]
)

// Running a keepOpen row (a toggle) changes state the rows themselves report,
// so the groups have to rebuild without the palette closing. A counter keeps
// that generic — the palette never learns which stores its contributions read.
const [selectTick, setSelectTick] = useState(0)

const contributedItems = usePaletteContributions()

// The active repo's worktrees → "new conversation in <branch>". This is the
// ⌘K-typed "I want to work on <branch>" reflex: each entry seeds a fresh
// session anchored to that worktree's checkout (requestStartWorkSession),
// so git is the source of truth and edits land in the right tree.
const branchGroup = useMemo<PaletteGroup[]>(
() =>
worktrees.length > 0
? [
{
heading: t.commandCenter.branches,
items: worktrees.map(wt => {
const name = wt.branch?.trim() || wt.path.split('/').pop() || wt.path

return {
icon: GitBranch,
id: `worktree-${wt.path}`,
keywords: ['branch', 'worktree', 'switch', name, wt.path],
label: t.commandCenter.startInBranch(name),
run: () => requestStartWorkSession(wt.path)
}
})
}
]
: [],
[t, worktrees]
)

const baseGroups = useMemo<PaletteGroup[]>(() => {
const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}`
const cc = t.commandCenter
Expand Down Expand Up @@ -688,30 +732,10 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) {
]
}

// The active repo's worktrees → "new conversation in <branch>". This is the
// ⌘K-typed "I want to work on <branch>" reflex: each entry seeds a fresh
// session anchored to that worktree's checkout (requestStartWorkSession),
// so git is the source of truth and edits land in the right tree.
const branchGroup: PaletteGroup[] =
worktrees.length > 0
? [
{
heading: cc.branches,
items: worktrees.map(wt => {
const name = wt.branch?.trim() || wt.path.split('/').pop() || wt.path

return {
icon: GitBranch,
id: `worktree-${wt.path}`,
keywords: ['branch', 'worktree', 'switch', name, wt.path],
label: cc.startInBranch(name),
run: () => requestStartWorkSession(wt.path)
}
})
}
]
: []

// Group order is the tiebreaker rankGroups falls back on (stable sort), and
// exact ties are the common case — "yolo" hits both "Toggle yolo" and a
// worktree named bb/yolo-* as a whole word. So this order IS the priority:
// where you're going, then what you can do, then what you can configure.
return [
{
heading: cc.goTo,
Expand Down Expand Up @@ -793,7 +817,28 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) {
]
},
projectGroup,
...branchGroup,
// Registry-contributed rows (core features + plugins) — one group,
// omitted while nothing contributes.
...(contributedItems.length > 0
? [
{
heading: cc.commands,
items: contributedItems.map(item => ({
action: item.action,
// Read on mount and after every select (the deps below), so a
// row that reports state can't show the state it just left.
detail: item.detail?.(),
detailVariant: item.detailVariant,
icon: item.icon ?? Zap,
id: item.key,
keepOpen: item.keepOpen,
keywords: item.keywords,
label: item.label,
run: item.run
}))
}
]
: []),
{
heading: cc.commandCenter,
items: [
Expand Down Expand Up @@ -889,26 +934,13 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) {
run: go(settingsTab(entry.tab))
}))
]
},
// Registry-contributed rows (core features + plugins) — one group,
// omitted while nothing contributes.
...(contributedItems.length > 0
? [
{
heading: cc.commands,
items: contributedItems.map(item => ({
action: item.action,
icon: item.icon ?? Zap,
id: item.key,
keywords: item.keywords,
label: item.label,
run: item.run
}))
}
]
: [])
}
]
}, [contributedItems, go, projectTree, settingsSectionLabel, t, updateVersionLabel, worktrees])
// `selectTick` is a deliberate re-read trigger, not a value: rows report
// live state through `detail()`, so the groups must rebuild after a select
// that kept the palette open — eslint only sees an unused dep.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contributedItems, go, projectTree, selectTick, settingsSectionLabel, t, updateVersionLabel])

// The long, granular lists (settings fields, API keys, MCP servers, archived
// chats) only surface once the user types — otherwise they'd bury the
Expand Down Expand Up @@ -1105,7 +1137,14 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) {
themeName
])

const groups = useMemo(() => [...baseGroups, ...searchGroups], [baseGroups, searchGroups])
// Branch rows rank below BOTH the fixed groups and the typed-only lists: they
// scale with whatever worktrees happen to exist, so on a tie they're the least
// likely thing meant. Everything above is either always-present chrome or a
// list the search itself asked for.
const groups = useMemo(
() => [...baseGroups, ...searchGroups, ...branchGroup],
[baseGroups, branchGroup, searchGroups]
)

// Nested palette pages (VS Code-style submenus). Reusable: add an entry here
// and point a root item at it via `to`.
Expand Down Expand Up @@ -1208,7 +1247,13 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) {

if (!item.keepOpen) {
closeCommandPalette()

return
}

// Staying open means the rows are still on screen — re-read anything they
// report (a toggle's on/off) so the note isn't showing the previous state.
setSelectTick(tick => tick + 1)
}

return (
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/app/command-palette/pet-palette-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export function PetInlineToggle() {
const toggle = () => {
void setPetEnabled(requestGateway, !enabled, {
noneAvailable: copy.noneAvailable,
fallback: copy.toggleFailed
fallback: copy.toggleFailed(!enabled)
}).then(ok => ok && triggerHaptic('crisp'))
}

Expand Down
Loading
Loading