feat(desktop): render backend-authoritative projects sidebar

This commit is contained in:
Brooklyn Nicholson 2026-06-25 16:40:27 -05:00
parent 74352a1e61
commit 488ae376db
23 changed files with 3854 additions and 1192 deletions

View file

@ -0,0 +1,158 @@
import type * as React from 'react'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
// Shared, content-agnostic sidebar chrome — used by both the flat session
// sections and the project/workspace tree, so it lives outside either to keep
// imports one-directional (no index <-> projects cycle).
/** `loaded/total` when there's more on the server, else just the loaded count. */
export const countLabel = (loaded: number, total: number): string =>
total > loaded ? `${loaded}/${total}` : String(loaded)
/** The muted count chip next to a section/workspace label. */
export function SidebarCount({ children }: { children: React.ReactNode }) {
return <span className="text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{children}</span>
}
// ── Row geometry (session row is canonical — everything composes these) ─────
//
// Height lives ONLY on SidebarRowShell (min-h-[1.625rem]). Inset children
// stretch to fill the cell and center content internally — never items-center
// on the shell grid, or short clusters (projects) float 12px off sessions.
const rowMinH = 'min-h-[1.625rem]'
const rowPadX = 'pl-2 pr-1'
const rowGap = 'gap-1.5'
const rowLead = 'grid size-3.5 shrink-0 place-items-center'
const rowInset = cn(rowPadX, rowGap, 'flex h-full min-w-0 items-center self-stretch py-0.5')
const rowLabel = 'min-w-0 truncate text-[0.8125rem] leading-none text-(--ui-text-secondary)'
/** Codicon size in sidebar row leads — matches the file tree (`tree.tsx`). */
export const SIDEBAR_LEAD_ICON_SIZE = '0.875rem' as const
/** Vertical stack of rows (gap-px, single column). */
export function SidebarRowStack({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('grid grid-cols-[minmax(0,1fr)] gap-px', className)} {...props} />
}
/** Nested rows (session previews, worktree bodies). */
export function SidebarRowNest({ className, ...props }: React.ComponentProps<'div'>) {
return <SidebarRowStack className={cn('pb-1 pl-4', className)} {...props} />
}
/** Outer grid — sole owner of row height. */
export function SidebarRowShell({
actions,
children,
className,
...props
}: React.ComponentProps<'div'> & { actions?: React.ReactNode }) {
return (
<div className={cn(rowMinH, 'grid grid-cols-[minmax(0,1fr)_auto] items-stretch rounded-md', className)} {...props}>
{children}
{actions ? <div className="flex shrink-0 items-center self-center">{actions}</div> : null}
</div>
)
}
/** Multi-control left cluster (project rows). */
export function SidebarRowCluster({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn(rowInset, className)} {...props} />
}
/** Session row main tap target. */
export function SidebarRowBody({ className, ...props }: React.ComponentProps<'button'>) {
return <button className={cn(rowInset, 'bg-transparent text-left', className)} type="button" {...props} />
}
/** Tappable label — underline/truncate live on the inner span, not the button. */
export function SidebarRowLink({
className,
labelClassName,
children,
...props
}: React.ComponentProps<'button'> & { labelClassName?: string }) {
return (
<button className={cn('min-w-0 shrink bg-transparent p-0 text-left', className)} type="button" {...props}>
<span className={cn(rowLabel, labelClassName)}>{children}</span>
</button>
)
}
/** Fixed leading column (dot, icon, drag handle). */
export function SidebarRowLead({ className, ...props }: React.ComponentProps<'span'>) {
return <span className={cn(rowLead, className)} {...props} />
}
/** Standard row label typography. */
export function SidebarRowLabel({ className, ...props }: React.ComponentProps<'span'>) {
return <span className={cn(rowLabel, className)} {...props} />
}
/** Dot ↔ grabber swap for dnd-kit reorder rows. */
export function SidebarRowGrab({
ariaLabel,
children,
className,
dragging = false,
dragHandleProps,
leadClassName
}: {
ariaLabel: string
children: React.ReactNode
className?: string
dragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLElement>
leadClassName?: string
}) {
return (
<SidebarRowLead
{...dragHandleProps}
aria-label={ariaLabel}
className={cn(
'group/handle relative cursor-grab touch-none overflow-hidden active:cursor-grabbing',
leadClassName,
className
)}
data-reorder-handle
onClick={event => event.stopPropagation()}
>
<span className="grid size-full place-items-center transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0">
{children}
</span>
<Codicon
className={cn(
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/handle:opacity-80 group-focus-within/handle:opacity-80 hover:text-(--ui-text-secondary)',
dragging && 'text-(--ui-text-secondary) opacity-100'
)}
name="grabber"
size="0.75rem"
/>
</SidebarRowLead>
)
}
/** Icon/dot slot inside SidebarRowLead — caps visual size so rows align. */
export function SidebarRowLeadGlyph({
children,
className,
style
}: {
children: React.ReactNode
className?: string
style?: React.CSSProperties
}) {
return (
<span
className={cn(
'grid size-full place-items-center text-(--ui-text-tertiary) [&_.codicon]:leading-none',
className
)}
style={style}
>
{children}
</span>
)
}

View file

@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { SidebarGroup, SidebarGroupContent } from '@/components/ui/sidebar'
import { Tip } from '@/components/ui/tooltip'
import { getCronJobRuns, type SessionInfo } from '@/hermes'
@ -328,7 +329,7 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s
<div className="mb-1 ml-[1.375rem] flex flex-col gap-px">
{runs === null ? (
<div className="flex items-center gap-1.5 py-1 pl-1 text-[0.6875rem] text-(--ui-text-tertiary)">
<Codicon name="loading" size="0.75rem" spinning />
<GlyphSpinner ariaLabel={c.loading} className="text-[0.75rem]" />
</div>
) : runs.length === 0 ? (
<div className="py-1 pl-1 text-[0.6875rem] text-(--ui-text-tertiary)">{c.noRuns}</div>

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
interface SidebarLoadMoreRowProps {
@ -7,24 +8,22 @@ interface SidebarLoadMoreRowProps {
loading?: boolean
}
// "Load N more" affordance shared by the recents, messaging, and cron sections.
// The chevron sits in the same w-3.5 column the rows use for their dot, so it
// lines up with the list above.
// Compact "load more" affordance shared by recents, messaging, and cron. Kept
// intentionally identical to workspace "show more" controls (ellipsis button)
// so pagination reads as one interaction everywhere.
export function SidebarLoadMoreRow({ step, onClick, loading = false }: SidebarLoadMoreRowProps) {
const { t } = useI18n()
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
return (
<button
className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
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"
>
<span className="grid w-3.5 shrink-0 place-items-center">
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
</span>
<span>{label}</span>
{loading ? <GlyphSpinner ariaLabel={label} className="text-[0.75rem]" /> : <Codicon name="ellipsis" size="0.75rem" />}
</button>
)
}

View file

@ -1,3 +1,12 @@
/** New ids first, then ids still present in the persisted order. */
export function reconcileFreshFirst(currentIds: string[], orderIds: string[]): string[] {
const current = new Set(currentIds)
const retained = orderIds.filter(id => current.has(id))
const retainedSet = new Set(retained)
return [...currentIds.filter(id => !retainedSet.has(id)), ...retained]
}
export function resolveManualSessionOrderIds(currentIds: string[], orderIds: string[], manual: boolean): string[] {
if (!manual || !currentIds.length || !orderIds.length) {
return []
@ -10,8 +19,5 @@ export function resolveManualSessionOrderIds(currentIds: string[], orderIds: str
return []
}
const retainedSet = new Set(retained)
const fresh = currentIds.filter(id => !retainedSet.has(id))
return [...fresh, ...retained]
return reconcileFreshFirst(currentIds, orderIds)
}

View file

@ -24,6 +24,7 @@ import { useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ColorSwatches } from '@/components/ui/color-swatches'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
@ -494,30 +495,14 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
side="top"
>
<div className="grid grid-cols-6 gap-1.5">
{PROFILE_SWATCHES.map(swatch => (
<button
aria-label={p.setColor(swatch)}
className="size-5 rounded-full transition-transform hover:scale-110"
key={swatch}
onClick={() => pickColor(swatch)}
style={{
backgroundColor: swatch,
boxShadow: swatch === color ? '0 0 0 2px var(--ui-bg-elevated), 0 0 0 3.5px currentColor' : undefined,
color: swatch
}}
type="button"
/>
))}
</div>
<button
className="mt-2 flex w-full items-center justify-center gap-1.5 rounded-md py-1 text-xs text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={() => pickColor(null)}
type="button"
>
<Codicon name="sync" size="0.75rem" />
{p.autoColor}
</button>
<ColorSwatches
clearIcon="sync"
clearLabel={p.autoColor}
onChange={pickColor}
swatches={PROFILE_SWATCHES}
swatchLabel={p.setColor}
value={color}
/>
</PopoverContent>
</Popover>
)

View file

@ -0,0 +1,289 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { GenerateButton } from '@/components/ui/generate-button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { useI18n } from '@/i18n'
import { type ProjectIdeaTemplate, randomIdeaTemplates } from '@/lib/project-idea-templates'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import {
$projectDialog,
addProjectFolder,
closeProjectDialog,
createProject,
generateProjectIdea,
pickProjectFolder,
renameProject
} from '@/store/projects'
// Single dialog mounted once in the sidebar; it renders create / rename /
// add-folder flows driven by the $projectDialog atom. Folders are chosen via
// the native directory picker (reused from the default-project-dir setting).
export function ProjectDialog() {
const { t } = useI18n()
const p = t.sidebar.projects
const state = useStore($projectDialog)
const open = state !== null
const mode = state?.mode ?? 'create'
const [name, setName] = useState('')
const [folders, setFolders] = useState<string[]>([])
const [idea, setIdea] = useState('')
const [templates, setTemplates] = useState<ProjectIdeaTemplate[]>([])
const [generatingIdea, setGeneratingIdea] = useState(false)
const [submitting, setSubmitting] = useState(false)
const nameRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
setName(state?.name ?? '')
setFolders([])
setIdea('')
setTemplates(randomIdeaTemplates())
setGeneratingIdea(false)
setSubmitting(false)
if (mode !== 'add-folder') {
window.setTimeout(() => nameRef.current?.select(), 0)
}
}
}, [open, mode, state?.name])
const onOpenChange = (next: boolean) => {
if (!next) {
closeProjectDialog()
}
}
// One submit beat for every flow: guard re-entry, run the write, close on
// success, surface a toast on failure. Callers pass only the write.
const runSubmit = async (write: () => Promise<unknown>) => {
if (submitting) {
return
}
setSubmitting(true)
try {
await write()
closeProjectDialog()
} catch (err) {
notifyError(err, p.createFailed)
} finally {
setSubmitting(false)
}
}
const pickFolder = async () => {
const dir = await pickProjectFolder()
if (!dir) {
return
}
const projectId = state?.projectId
if (mode === 'add-folder' && projectId) {
await runSubmit(() => addProjectFolder(projectId, dir))
return
}
setFolders(prev => (prev.includes(dir) ? prev : [...prev, dir]))
}
const submit = async () => {
const trimmed = name.trim()
const projectId = state?.projectId
if (mode === 'rename' && projectId) {
if (trimmed) {
await runSubmit(() => renameProject(projectId, trimmed))
}
return
}
// A project owns sessions by folder (cwd-prefix), so creation requires at
// least one — a folder-less project couldn't hold a session anyway.
if (mode === 'create' && trimmed && folders.length) {
await runSubmit(() => createProject({ folders, idea: idea.trim() || undefined, name: trimmed, use: true }))
}
}
const generateIdea = async () => {
if (generatingIdea) {
return
}
setGeneratingIdea(true)
try {
const text = await generateProjectIdea(name)
if (text) {
setIdea(text)
}
} finally {
setGeneratingIdea(false)
}
}
const title = mode === 'rename' ? p.renameTitle : mode === 'add-folder' ? p.addFolderTitle : p.createTitle
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{mode === 'create' && <DialogDescription>{p.createDesc}</DialogDescription>}
</DialogHeader>
{mode !== 'add-folder' && (
<Input
autoFocus
disabled={submitting}
onChange={event => setName(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
void submit()
} else if (event.key === 'Escape') {
onOpenChange(false)
}
}}
placeholder={p.namePlaceholder}
ref={nameRef}
value={name}
/>
)}
{mode === 'create' && (
<div className="flex flex-col gap-1.5">
<span className="text-[0.6875rem] font-medium text-(--ui-text-tertiary)">{p.foldersLabel}</span>
{folders.length === 0 ? (
<span className="text-[0.75rem] text-(--ui-text-quaternary)">{p.noFolders}</span>
) : (
<ul className="flex flex-col gap-1">
{folders.map((folder, index) => (
<li
className={cn(
'flex items-center gap-2 rounded-md bg-(--ui-control-hover-background) px-2 py-1 text-[0.75rem]'
)}
key={folder}
>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="folder" size="0.75rem" />
<span className="min-w-0 flex-1 truncate" title={folder}>
{folder}
</span>
{index === 0 && (
<span className="shrink-0 text-[0.625rem] uppercase text-(--ui-text-quaternary)">
{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>
</li>
))}
</ul>
)}
<Button
className="self-start"
disabled={submitting}
onClick={() => void pickFolder()}
size="sm"
type="button"
variant="ghost"
>
<Codicon name="add" size="0.75rem" />
{p.addFolder}
</Button>
</div>
)}
{mode === 'create' && (
<div className="flex flex-col gap-1.5">
<span className="text-[0.6875rem] font-medium text-(--ui-text-tertiary)">{p.ideaLabel}</span>
<div className="relative">
<Textarea
className="min-h-20 pr-8 text-[0.8125rem]"
disabled={submitting}
onChange={event => setIdea(event.target.value)}
placeholder={p.ideaPlaceholder}
value={idea}
/>
<GenerateButton
className="absolute top-1 right-1"
disabled={submitting}
generating={generatingIdea}
generatingLabel={p.ideaGenerating}
label={p.ideaGenerate}
onGenerate={() => void generateIdea()}
/>
</div>
<div className="flex flex-wrap items-center gap-1">
{templates.map(template => (
<button
className="flex items-center gap-1 rounded-full border border-(--ui-stroke-tertiary) px-2 py-0.5 text-[0.6875rem] text-(--ui-text-secondary) transition-colors hover:border-(--ui-stroke-secondary) hover:bg-(--ui-control-hover-background) hover:text-foreground disabled:opacity-50"
disabled={submitting}
key={template.label}
onClick={() => setIdea(template.idea)}
type="button"
>
<span aria-hidden>{template.emoji}</span>
{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>
</div>
</div>
)}
{mode === 'add-folder' && (
<Button disabled={submitting} onClick={() => void pickFolder()} type="button">
<Codicon name="folder-opened" size="0.875rem" />
{p.addFolder}
</Button>
)}
{mode !== 'add-folder' && (
<DialogFooter>
<Button disabled={submitting} onClick={() => onOpenChange(false)} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button
disabled={submitting || !name.trim() || (mode === 'create' && folders.length === 0)}
onClick={() => void submit()}
type="button"
>
{mode === 'rename' ? t.common.save : p.create}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,265 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import type { HermesGitWorktree } from '@/global'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { $dismissedWorktreeIds, dismissWorktree } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { removeWorktreePath } from '@/store/projects'
import { SidebarRowStack } from '../chrome'
import { useWorkspaceNodeOpen } from './model'
import { SidebarWorkspaceGroup } from './workspace-group'
import {
mergeRepoWorktreeGroups,
overlayRepoLanes,
type SidebarProjectTree,
type SidebarSessionGroup,
type SidebarWorkspaceTree
} from './workspace-groups'
import { WorkspaceAddButton, WorkspaceHeader } from './workspace-header'
// The entered project's body. Main-checkout sessions render directly — no
// redundant repo/branch header (the breadcrumb already names the project). Only
// linked worktrees nest, shown by branch. Multi-folder projects keep per-repo
// headers so the folders stay distinguishable.
export function EnteredProjectContent({
project,
renderRows,
onNewSession,
repoWorktrees,
liveSessions,
removedSessionIds
}: {
project: SidebarProjectTree
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
repoWorktrees?: Record<string, HermesGitWorktree[]>
liveSessions?: SessionInfo[]
removedSessionIds?: ReadonlySet<string>
}) {
if (!project.repos.length) {
return null
}
const single = project.repos.length === 1
return (
<>
{project.repos.map(repo => (
<RepoFlatSection
discoveredWorktrees={repo.path ? repoWorktrees?.[repo.path] : undefined}
key={repo.id}
liveSessions={liveSessions}
onNewSession={onNewSession}
removedSessionIds={removedSessionIds}
renderRows={renderRows}
repo={repo}
showHeader={!single}
/>
))}
</>
)
}
function RepoFlatSection({
repo,
showHeader,
renderRows,
onNewSession,
discoveredWorktrees,
liveSessions,
removedSessionIds
}: {
repo: SidebarWorkspaceTree
showHeader: boolean
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
discoveredWorktrees?: HermesGitWorktree[]
liveSessions?: SessionInfo[]
removedSessionIds?: ReadonlySet<string>
}) {
const { t } = useI18n()
const s = t.sidebar
const [open, toggleOpen] = useWorkspaceNodeOpen(repo.id)
const dismissedWorktrees = useStore($dismissedWorktreeIds)
// The repo's session lanes already come fully built from the backend; this
// only injects empty VISUAL lanes from a live `git worktree list`.
const mergedGroups = useMemo(() => mergeRepoWorktreeGroups(repo, discoveredWorktrees), [repo, discoveredWorktrees])
// Optimistic placement runs against the MERGED lane set (backend + visual
// git-worktree lanes) so out-of-tree/sibling worktrees — which exist as visual
// lanes before the snapshot carries their sessions — get the new row. The
// overlay drops lanes it empties, so re-merge to restore still-real worktrees.
const overlaidGroups = useMemo(() => {
if (!(liveSessions?.length || removedSessionIds?.size)) {
return mergedGroups
}
const { groups } = overlayRepoLanes({ ...repo, groups: mergedGroups }, liveSessions ?? [], removedSessionIds)
return mergeRepoWorktreeGroups({ id: repo.id, path: repo.path, groups }, discoveredWorktrees)
}, [repo, mergedGroups, discoveredWorktrees, liveSessions, removedSessionIds])
const discoveredWorktreePaths = useMemo(
() =>
new Set(
(discoveredWorktrees ?? [])
.map(worktree => worktree.path?.trim())
.filter((path): path is string => Boolean(path))
),
[discoveredWorktrees]
)
// Main lanes are always visible; linked worktrees can be user-dismissed.
// A live `git worktree list` hit wins over an old dismissal: if git says the
// worktree exists again (or still exists after "hide from sidebar"), surface it.
const ordered = overlaidGroups.filter(
group => group.isMain || !dismissedWorktrees.includes(group.id) || (group.path && discoveredWorktreePaths.has(group.path))
)
const repoCount = ordered.reduce((sum, group) => sum + group.sessions.length, 0)
// Removal asks how: actually `git worktree remove` it, or just hide the lane
// and leave the worktree on disk. A dirty worktree escalates to a force prompt
// instead of erroring (those changes are usually throwaway).
const [removeTarget, setRemoveTarget] = useState<null | SidebarSessionGroup>(null)
const [forceTarget, setForceTarget] = useState<null | SidebarSessionGroup>(null)
const removeViaGit = async (group: SidebarSessionGroup, force = false) => {
if (!repo.path || !group.path) {
return
}
try {
await removeWorktreePath(repo.path, group.path, { force })
dismissWorktree(group.id)
} catch (err) {
// git refuses a non-force remove on a dirty/locked worktree — offer force
// rather than dead-ending on an error toast.
if (!force && /force|modified|untracked|dirty|locked|contains/i.test(String((err as Error)?.message ?? ''))) {
setForceTarget(group)
} else {
notifyError(err, s.projects.removeWorktreeFailed)
}
}
}
const body = (
<>
{ordered.map(group => (
<SidebarWorkspaceGroup
group={group}
key={group.id}
// The kanban bucket is read-only: it aggregates many task worktrees, so
// "new session here" and "remove worktree" have no single target.
onNewSession={group.isKanban ? undefined : onNewSession}
onRemove={group.isMain || group.isKanban ? undefined : () => setRemoveTarget(group)}
renderRows={renderRows}
/>
))}
</>
)
// Both removal prompts share the shape (hide-from-sidebar + cancel + a
// destructive action); only the copy and the destructive handler differ.
const worktreeDialog = (
target: null | SidebarSessionGroup,
setTarget: (next: null | SidebarSessionGroup) => void,
description: string,
destructiveLabel: string,
onDestructive: (group: SidebarSessionGroup) => void
) => (
<Dialog onOpenChange={isOpen => !isOpen && setTarget(null)} open={Boolean(target)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{`${s.projects.removeWorktree} "${target?.label ?? ''}"?`}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setTarget(null)} variant="ghost">
{t.common.cancel}
</Button>
<Button
onClick={() => {
if (target) {
dismissWorktree(target.id)
}
setTarget(null)
}}
variant="secondary"
>
{s.projects.removeFromSidebar}
</Button>
<Button
onClick={() => {
setTarget(null)
if (target) {
onDestructive(target)
}
}}
variant="destructive"
>
{destructiveLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
const removeDialog = (
<>
{worktreeDialog(
removeTarget,
setRemoveTarget,
s.projects.removeWorktreeConfirm,
s.projects.removeWorktree,
group => void removeViaGit(group)
)}
{worktreeDialog(
forceTarget,
setForceTarget,
s.projects.removeWorktreeDirty,
s.projects.forceRemove,
group => void removeViaGit(group, true)
)}
</>
)
if (!showHeader) {
return (
<>
{body}
{removeDialog}
</>
)
}
return (
<SidebarRowStack>
<WorkspaceHeader
action={
onNewSession && <WorkspaceAddButton label={s.newSessionIn(repo.label)} onClick={() => onNewSession(repo.path)} />
}
count={repoCount}
emphasis
icon={<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="repo" size="0.75rem" />}
label={repo.label}
onToggle={toggleOpen}
open={open}
title={repo.path ?? undefined}
/>
{open && <SidebarRowStack className="pl-2.5">{body}</SidebarRowStack>}
{removeDialog}
</SidebarRowStack>
)
}

View file

@ -0,0 +1,15 @@
// Public surface of the project/worktree sidebar, consumed by the sidebar root.
export { EnteredProjectContent } from './entered-content'
export { PROJECT_PREVIEW_COUNT, projectTreeCwd, sortProjectsForOverview, useRepoWorktreeMap } from './model'
export { ProjectBackRow, ProjectOverviewRow } from './overview-row'
export { ProjectMenu } from './project-menu'
export { SidebarWorkspaceGroup } from './workspace-group'
export {
overlayLiveLanes,
overlayLivePreviews,
sessionRecency,
type SidebarProjectTree,
type SidebarSessionGroup,
type SidebarWorkspaceTree
} from './workspace-groups'
export { StartWorkButton } from './workspace-header'

View file

@ -0,0 +1,128 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useState } from 'react'
import type { HermesGitWorktree } from '@/global'
import type { SessionInfo } from '@/hermes'
import { mapPool } from '@/lib/pool'
import { $sidebarWorkspaceCollapsedIds, toggleWorkspaceNodeCollapsed } from '@/store/layout'
import { $worktreeRefreshToken } from '@/store/projects'
import { sessionRecency, type SidebarProjectTree } from './workspace-groups'
// Page size when revealing more already-loaded rows within a workspace group.
export const SIDEBAR_GROUP_PAGE = 5
// Recent sessions previewed under each project in the overview.
export const PROJECT_PREVIEW_COUNT = 3
// Max concurrent `git worktree list` probes when a project spans many repos.
const WORKTREE_PROBE_CONCURRENCY = 4
const pathListKey = (paths: string[]): string =>
paths.map(path => path.trim()).filter(Boolean).sort((a, b) => a.localeCompare(b)).join('\n')
// Every session in a project, across its repos/worktrees (order-agnostic).
const projectSessions = (project: SidebarProjectTree): SessionInfo[] =>
project.repos.flatMap(repo => repo.groups.flatMap(group => group.sessions))
export const projectTreeCwd = (project: SidebarProjectTree): null | string =>
project.path || project.repos.find(repo => repo.path)?.path || null
// Overview rows carry their activity stamp from the backend (lanes are empty in
// overview mode), falling back to loaded session times when present.
const projectActivityTime = (project: SidebarProjectTree): number =>
Math.max(
project.lastActive ?? 0,
projectSessions(project).reduce((latest, s) => Math.max(latest, sessionRecency(s)), 0)
)
// The project's most-recent sessions, for the overview preview under each row.
export const latestProjectSessions = (project: SidebarProjectTree, limit: number): SessionInfo[] =>
[...projectSessions(project)].sort((a, b) => sessionRecency(b) - sessionRecency(a)).slice(0, limit)
export function sortProjectsForOverview(
projects: SidebarProjectTree[],
activeProjectId: null | string
): SidebarProjectTree[] {
return [...projects].sort((a, b) => {
const aActive = Boolean(activeProjectId && a.id === activeProjectId && !a.isAuto)
const bActive = Boolean(activeProjectId && b.id === activeProjectId && !b.isAuto)
if (aActive !== bActive) {
return aActive ? -1 : 1
}
if (!a.isAuto !== !b.isAuto) {
return a.isAuto ? 1 : -1
}
const aHasSessions = a.sessionCount > 0
const bHasSessions = b.sessionCount > 0
if (aHasSessions !== bHasSessions) {
return aHasSessions ? -1 : 1
}
return projectActivityTime(b) - projectActivityTime(a) || a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
})
}
// Project drill-in lanes are git-driven: source them from `git worktree list` so
// linked worktrees still appear even when their sessions aren't in the recents
// payload currently loaded in memory.
export function useRepoWorktreeMap(
repoPaths: string[],
enabled: boolean
): [Record<string, HermesGitWorktree[]>, boolean] {
const [map, setMap] = useState<Record<string, HermesGitWorktree[]>>({})
const [loading, setLoading] = useState(false)
const key = useMemo(() => pathListKey(repoPaths), [repoPaths])
// Refetch when a worktree is added/removed so a new lane shows immediately.
const refreshToken = useStore($worktreeRefreshToken)
useEffect(() => {
const git = window.hermesDesktop?.git
if (!enabled || !repoPaths.length || !git?.worktreeList) {
setMap({})
setLoading(false)
return
}
let cancelled = false
setLoading(true)
// Bounded so a many-repo project doesn't spawn a `git` process per repo at once.
void mapPool(repoPaths, WORKTREE_PROBE_CONCURRENCY, async repoPath => {
try {
return [repoPath, await git.worktreeList(repoPath)] as const
} catch {
return [repoPath, []] as const
}
})
.then(entries => void (cancelled || setMap(Object.fromEntries(entries))))
.finally(() => void (cancelled || setLoading(false)))
return () => {
cancelled = true
}
}, [enabled, key, repoPaths, refreshToken])
return [map, loading]
}
// Persisted open/collapse for a repo/worktree node. Lets a project's folder
// layout auto-restore when you enter it, and survive reloads.
//
// The persisted set is an OVERRIDE of `defaultOpen`, not an absolute "collapsed"
// list: XOR lets one store serve both polarities. A default-open node (repo,
// populated lane) lists collapses; a default-collapsed node (an EMPTY lane — no
// sessions yet) instead records an explicit expand. So empty worktree/branch
// lanes start collapsed and only open when the user clicks in.
export function useWorkspaceNodeOpen(id: string, defaultOpen = true): [boolean, () => void] {
const collapsed = useStore($sidebarWorkspaceCollapsedIds)
const overridden = collapsed.includes(id)
return [defaultOpen ? !overridden : overridden, () => toggleWorkspaceNodeCollapsed(id)]
}

View file

@ -0,0 +1,155 @@
import type * as React from 'react'
import { useRef } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import {
SIDEBAR_LEAD_ICON_SIZE,
SidebarRowBody,
SidebarRowCluster,
SidebarRowGrab,
SidebarRowLabel,
SidebarRowLead,
SidebarRowLeadGlyph,
SidebarRowLink,
SidebarRowNest,
SidebarRowShell
} from '../chrome'
import { latestProjectSessions, PROJECT_PREVIEW_COUNT, useWorkspaceNodeOpen } from './model'
import { ProjectMenu } from './project-menu'
import type { SidebarProjectTree } from './workspace-groups'
import { WorkspaceAddButton } from './workspace-header'
// A bare color dot (no icon) or an icon glyph — tinted by `color` when set, else
// the lead's default tertiary. The glyph wrapper centers + caps size either way.
export function projectIcon({ color, icon }: SidebarProjectTree) {
if (color && !icon) {
return (
<SidebarRowLeadGlyph>
<span aria-hidden="true" className="size-1 rounded-full" style={{ backgroundColor: color }} />
</SidebarRowLeadGlyph>
)
}
return (
<SidebarRowLeadGlyph style={color ? { color } : undefined}>
<Codicon name={icon || 'folder-library'} size={SIDEBAR_LEAD_ICON_SIZE} />
</SidebarRowLeadGlyph>
)
}
export function ProjectBackRow({ label, onClick }: { label: string; onClick: () => void }) {
return (
<SidebarRowShell>
<SidebarRowBody
className="group/back w-full text-(--ui-text-tertiary) opacity-40 hover:text-foreground"
onClick={onClick}
>
<SidebarRowLead>
<SidebarRowLeadGlyph>
<Codicon name="arrow-left" size={SIDEBAR_LEAD_ICON_SIZE} />
</SidebarRowLeadGlyph>
</SidebarRowLead>
<SidebarRowLabel className="text-xs underline-offset-4 group-hover/back:underline">{label}</SidebarRowLabel>
</SidebarRowBody>
</SidebarRowShell>
)
}
interface ProjectOverviewRowProps {
project: SidebarProjectTree
onEnter?: (id: string) => void
onNewSession?: (path: null | string) => void
renderRows?: (sessions: SessionInfo[]) => React.ReactNode
activeProjectId?: null | string
previewSessions?: SessionInfo[]
reorderable?: boolean
dragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLElement>
ref?: React.Ref<HTMLDivElement>
style?: React.CSSProperties
}
export function ProjectOverviewRow({
project,
onEnter,
onNewSession,
renderRows,
activeProjectId,
previewSessions,
reorderable = false,
dragging = false,
dragHandleProps,
ref,
style
}: ProjectOverviewRowProps) {
const { t } = useI18n()
const s = t.sidebar
const isActive = project.id === activeProjectId
const [open, toggleOpen] = useWorkspaceNodeOpen(project.id)
// The appearance popover anchors here (the full row) so it opens flush with
// the sidebar's content edge regardless of which side the sidebar is on.
const rowRef = useRef<HTMLDivElement>(null)
const fetched = (previewSessions ?? []).slice(0, PROJECT_PREVIEW_COUNT)
const preview = renderRows ? (fetched.length ? fetched : latestProjectSessions(project, PROJECT_PREVIEW_COUNT)) : []
const lead = reorderable ? (
<SidebarRowGrab
ariaLabel={s.projects.reorder(project.label)}
dragging={dragging}
dragHandleProps={dragHandleProps}
leadClassName="overflow-visible"
>
{projectIcon(project)}
</SidebarRowGrab>
) : (
<SidebarRowLead>{projectIcon(project)}</SidebarRowLead>
)
return (
<div className={cn(dragging && 'relative z-10')} ref={ref} style={style}>
<SidebarRowShell
actions={
<>
{onNewSession && <WorkspaceAddButton label={s.newSessionIn(project.label)} onClick={() => onNewSession(project.path)} />}
<ProjectMenu anchorRef={rowRef} isActive={isActive} project={project} />
</>
}
className={cn('group/workspace', dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)')}
ref={rowRef}
>
<SidebarRowCluster className="min-w-0 flex-1">
{lead}
<SidebarRowLink
aria-label={s.projects.enter(project.label)}
labelClassName={cn('hover:text-foreground hover:underline', isActive && 'text-foreground')}
onClick={() => onEnter?.(project.id)}
>
{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>
) : (
<span className="flex-1" />
)}
</SidebarRowCluster>
</SidebarRowShell>
{open && preview.length > 0 && <SidebarRowNest>{renderRows?.(preview)}</SidebarRowNest>}
</div>
)
}

View file

@ -0,0 +1,206 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { ColorSwatches } from '@/components/ui/color-swatches'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { useI18n } from '@/i18n'
import { PROFILE_SWATCHES } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
import { $panesFlipped, dismissAutoProject } from '@/store/layout'
import {
copyPath,
deleteProject,
openProjectAddFolder,
openProjectRename,
revealPath,
setActiveProject,
updateProject
} from '@/store/projects'
import type { SidebarProjectTree } from './workspace-groups'
// Curated codicons for the project glyph (tinted by the chosen color).
const ICONS = [
'folder-library', 'repo', 'rocket', 'beaker', 'flame', 'star-full', 'heart',
'zap', 'target', 'lightbulb', 'tools', 'device-desktop', 'device-mobile', 'terminal',
'dashboard', 'globe', 'broadcast', 'cloud', 'database', 'package', 'book',
'organization', 'bug', 'shield', 'key', 'gift', 'telescope', 'home'
]
// Per-project actions, modeled on git GUIs (GitHub Desktop / GitKraken): reveal
// in the file manager, copy path, and "Remove from sidebar" (never deletes files
// — auto projects are dismissed, explicit ones drop their entry). Explicit
// projects additionally get rename / add folder / set active. Hidden until the
// row is hovered (group/workspace), matching the + affordance.
export function ProjectMenu({
project,
isActive,
scoped = false,
onExitScope,
anchorRef
}: {
project: SidebarProjectTree
isActive: boolean
// True when rendered in the entered-project header, so removal can leave the
// now-defunct scope.
scoped?: boolean
onExitScope?: () => void
// Anchor the appearance popover to the whole row instead of the kebab, so it
// opens flush against the sidebar's content-facing edge — otherwise a
// right-side sidebar drags the picker across the entire panel (the kebab
// lives at the row's outer edge). Falls back to the kebab when absent.
anchorRef?: React.RefObject<HTMLElement | null>
}) {
const { t } = useI18n()
const p = t.sidebar.projects
const target = { id: project.id, name: project.label }
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
const [appearanceOpen, setAppearanceOpen] = useState(false)
// Open toward the content area: right when the sidebar is on the left, left
// when the panes are flipped (sidebar on the right).
const panesFlipped = useStore($panesFlipped)
const removeAuto = () => {
dismissAutoProject(project.id)
if (scoped) {
onExitScope?.()
}
}
const confirmDelete = async () => {
await deleteProject(project.id)
if (scoped) {
onExitScope?.()
}
}
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>
)
return (
<Popover onOpenChange={setAppearanceOpen} open={appearanceOpen}>
{/* Position the appearance popover against the row (when a ref is wired);
the kebab is only the dropdown trigger then. */}
{anchorRef ? <PopoverAnchor virtualRef={anchorRef as React.RefObject<HTMLElement>} /> : null}
<DropdownMenu>
{anchorRef ? trigger : <PopoverAnchor asChild>{trigger}</PopoverAnchor>}
{/* Closing the menu refocuses the trigger (also the popover anchor),
which the appearance popover would read as focus-outside and die on.
Suppress that refocus so it survives. */}
<DropdownMenuContent align="end" className="w-48" onCloseAutoFocus={event => event.preventDefault()} sideOffset={6}>
{!project.isAuto && (
<>
<DropdownMenuItem onSelect={() => openProjectRename(target)}>
<Codicon name="edit" size="0.875rem" />
<span>{p.menuRename}</span>
</DropdownMenuItem>
<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>
</DropdownMenuItem>
<DropdownMenuItem disabled={isActive} onSelect={() => void setActiveProject(project.id)}>
<Codicon name="target" size="0.875rem" />
<span>{p.menuSetActive}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem disabled={!project.path} onSelect={() => void revealPath(project.path)}>
<Codicon name="folder-opened" size="0.875rem" />
<span>{p.reveal}</span>
</DropdownMenuItem>
<DropdownMenuItem disabled={!project.path} onSelect={() => void copyPath(project.path)}>
<Codicon name="copy" size="0.875rem" />
<span>{p.copyPath}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{project.isAuto ? (
<DropdownMenuItem onSelect={removeAuto} variant="destructive">
<Codicon name="trash" size="0.875rem" />
<span>{p.removeFromSidebar}</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem onSelect={() => setConfirmDeleteOpen(true)} variant="destructive">
<Codicon name="trash" size="0.875rem" />
<span>{`${p.menuDelete}`}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<PopoverContent
align="start"
className="w-auto p-2"
onClick={event => event.stopPropagation()}
side={panesFlipped ? 'left' : 'right'}
sideOffset={6}
>
<ColorSwatches
clearIcon="circle-slash"
clearLabel={p.noColor}
onChange={color => void updateProject(project.id, { color })}
swatches={PROFILE_SWATCHES}
value={project.color ?? null}
/>
{/* Same 6 columns + gap as the swatch grid so the popover keeps the
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={() => 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>
))}
</div>
</PopoverContent>
<ConfirmDialog
confirmLabel={p.menuDelete}
description={p.deleteConfirm}
destructive
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={confirmDelete}
open={confirmDeleteOpen}
title={`${p.menuDelete} "${project.label}"?`}
/>
</Popover>
)
}

View file

@ -0,0 +1,144 @@
import type * as React from 'react'
import { useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { notifyError } from '@/store/notifications'
import { newSessionInProfile } from '@/store/profile'
import { switchBranchInRepo } from '@/store/projects'
import { countLabel, SidebarRowStack } from '../chrome'
import { SidebarLoadMoreRow } from '../load-more-row'
import { SIDEBAR_GROUP_PAGE, useWorkspaceNodeOpen } from './model'
import type { SidebarSessionGroup } from './workspace-groups'
import { WorkspaceAddButton, WorkspaceHeader, WorkspaceMenu, WorkspaceShowMoreButton } from './workspace-header'
interface SidebarWorkspaceGroupProps {
group: SidebarSessionGroup
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
// When set (linked worktree rows), shows a remove affordance that runs a real
// `git worktree remove`.
onRemove?: () => void
}
export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemove }: SidebarWorkspaceGroupProps) {
const { t } = useI18n()
const s = t.sidebar
const isProfileGroup = group.mode === 'profile'
// Empty worktree/branch lanes start collapsed — they only show a "No sessions
// yet" placeholder, so defaulting them open just adds noise. Profile lanes and
// lanes that already hold sessions default open.
const defaultOpen = isProfileGroup || group.sessions.length > 0
const [open, toggleOpen] = useWorkspaceNodeOpen(group.id, defaultOpen)
const [visibleCount, setVisibleCount] = useState(SIDEBAR_GROUP_PAGE)
const loadedCount = group.sessions.length
// Profile groups know their on-disk total (children excluded); workspace
// groups only ever page within what's already loaded.
const totalCount = isProfileGroup ? Math.max(group.totalCount ?? loadedCount, loadedCount) : loadedCount
const visibleSessions = group.sessions.slice(0, visibleCount)
const hiddenCount = Math.max(0, totalCount - visibleSessions.length)
const nextCount = Math.min(SIDEBAR_GROUP_PAGE, hiddenCount)
// Leading glyph: profile color dot, a home mark for the repo's primary
// checkout (labeled by its live branch), or a branch/kanban mark otherwise.
const leadingIcon = group.color ? (
<span aria-hidden="true" className="size-2 shrink-0 rounded-full" style={{ backgroundColor: group.color }} />
) : (
<Codicon
className="shrink-0 text-(--ui-text-tertiary)"
name={group.isKanban ? 'checklist' : group.isHome ? 'home' : 'git-branch'}
size="0.75rem"
/>
)
// Reveal already-loaded rows first; only hit the backend when the next page
// crosses what's been fetched for this profile.
const handleProfileLoadMore = () => {
const target = visibleCount + SIDEBAR_GROUP_PAGE
setVisibleCount(target)
if (target > loadedCount && loadedCount < totalCount) {
group.onLoadMore?.()
}
}
const handleNewSession = async () => {
if (isProfileGroup) {
newSessionInProfile(group.id)
return
}
if (!onNewSession) {
return
}
// Main-checkout lanes are branch-labeled views over the same repo root path.
// Clicking "+" on `main` should open on `main`, not whatever branch the root
// currently sits on (`test0`, etc.), so explicitly switch first.
if (group.isMain && group.path && group.label) {
try {
await switchBranchInRepo(group.path, group.label)
} catch (err) {
notifyError(err, t.statusStack.coding.switchFailed(group.label))
return
}
}
onNewSession(group.path)
}
return (
<SidebarRowStack>
<WorkspaceHeader
action={
(onNewSession || isProfileGroup || onRemove) && (
<div className="flex items-center">
{(onNewSession || isProfileGroup) && (
<WorkspaceAddButton
label={s.newSessionIn(group.label)}
// Profile groups start a fresh session in that profile but keep
// the all-profiles browse view; workspace groups seed the new
// session's cwd. Main checkout lanes are branch-targeted.
onClick={() => void handleNewSession()}
/>
)}
{onRemove && <WorkspaceMenu onRemove={onRemove} path={group.path} />}
</div>
)
}
count={isProfileGroup ? countLabel(visibleSessions.length, totalCount) : group.sessions.length}
icon={leadingIcon}
label={group.label}
onToggle={toggleOpen}
open={open}
title={group.path ?? undefined}
/>
{open && (
<>
{visibleSessions.length === 0 ? (
<div className="min-h-7 pl-2 text-[0.75rem] leading-7 text-(--ui-text-quaternary)">{s.noSessions}</div>
) : (
renderRows(visibleSessions)
)}
{hiddenCount > 0 &&
(isProfileGroup ? (
<SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} />
) : (
<WorkspaceShowMoreButton
count={nextCount}
label={group.label}
onClick={() => setVisibleCount(count => count + SIDEBAR_GROUP_PAGE)}
/>
))}
</>
)}
</SidebarRowStack>
)
}

View file

@ -0,0 +1,635 @@
import { describe, expect, it } from 'vitest'
import type { HermesGitWorktree } from '@/global'
import type { ProjectInfo, SessionInfo } from '@/types/hermes'
import {
baseName,
kanbanWorktreeDir,
liveSessionProjectId,
mergeRepoWorktreeGroups,
overlayLiveLanes,
overlayLivePreviews,
type SidebarProjectTree,
type SidebarSessionGroup,
sortWorktreeGroups
} from './workspace-groups'
// The grouping itself now lives on the backend (tui_gateway/project_tree.py,
// covered by tests/tui_gateway/test_project_tree.py). This file only covers the
// thin render helpers the desktop still owns + the VISUAL worktree enhancer.
let nextId = 0
function makeSession(cwd: null | string, overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
archived: false,
cwd,
ended_at: null,
id: `s${nextId++}`,
input_tokens: 0,
is_active: false,
last_active: 1_000,
message_count: 1,
model: 'claude',
output_tokens: 0,
preview: null,
source: 'cli',
started_at: 1_000,
title: null,
tool_call_count: 0,
...overrides
}
}
const lane = (over: Partial<SidebarSessionGroup> & Pick<SidebarSessionGroup, 'id' | 'label'>): SidebarSessionGroup => ({
path: null,
sessions: [],
...over
})
describe('baseName', () => {
it('returns the final path segment, ignoring trailing slashes and separators', () => {
expect(baseName('/www/hermes-agent/')).toBe('hermes-agent')
expect(baseName('C:\\repos\\app')).toBe('app')
expect(baseName('')).toBeUndefined()
})
})
describe('kanbanWorktreeDir', () => {
it('matches a kanban task worktree (t_<hex>) and returns its .worktrees dir', () => {
expect(kanbanWorktreeDir('/repo/.worktrees/t_aaaaaaaa')).toBe('/repo/.worktrees')
})
it('does NOT match a user-named "New worktree" under .worktrees/ (its own lane)', () => {
expect(kanbanWorktreeDir('/repo/.worktrees/test-gui-stuff')).toBeNull()
})
it('returns null for non-kanban paths', () => {
expect(kanbanWorktreeDir('/repo/src')).toBeNull()
expect(kanbanWorktreeDir('/repo')).toBeNull()
})
})
describe('sortWorktreeGroups', () => {
it('pins trunk to the top, sinks kanban to the bottom, and orders the rest by recency', () => {
const at = (t: number) => [makeSession('/x', { last_active: t })]
const groups = [
lane({ id: 'k', label: 'kanban', isKanban: true, sessions: at(999) }),
lane({ id: 'stale', label: 'stale-branch', isMain: true, sessions: at(10) }),
lane({ id: 'wt', label: 'busy-worktree', isMain: false, sessions: at(500) }),
lane({ id: 'main', label: 'main', isMain: true, sessions: at(1) })
]
// main (trunk) first despite being least recent; kanban last despite being
// most recent; busy-worktree ahead of stale-branch by activity.
expect(sortWorktreeGroups(groups).map(g => g.label)).toEqual(['main', 'busy-worktree', 'stale-branch', 'kanban'])
})
it('pins the live home checkout above trunk, even when it has no sessions yet', () => {
const groups = [
lane({ id: 'main', label: 'main', isMain: true, sessions: [makeSession('/x', { last_active: 999 })] }),
lane({ id: 'home', label: 'bb/projects-paradigm', isMain: true, isHome: true })
]
expect(sortWorktreeGroups(groups).map(g => g.label)).toEqual(['bb/projects-paradigm', 'main'])
})
it('falls back to label order for equally-idle lanes', () => {
const groups = [
lane({ id: 'b', label: 'beta', isMain: false }),
lane({ id: 'a', label: 'alpha', isMain: false })
]
expect(sortWorktreeGroups(groups).map(g => g.label)).toEqual(['alpha', 'beta'])
})
})
describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
it('injects a linked worktree lane discovered by git that has no sessions yet', () => {
const repo = { id: '/repo', path: '/repo', groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })] }
const discovered: HermesGitWorktree[] = [
{ branch: 'feature', detached: false, isMain: false, locked: false, path: '/repo-wt-feature' }
]
const merged = mergeRepoWorktreeGroups(repo, discovered)
expect(merged.map(g => g.label)).toEqual(['main', 'feature'])
// The injected lane is empty (visual only — never carries sessions).
expect(merged.find(g => g.label === 'feature')?.sessions).toEqual([])
})
it('never spawns a lane per kanban task worktree', () => {
const repo = { id: '/repo', path: '/repo', groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })] }
const discovered: HermesGitWorktree[] = [
{ branch: 'wt/t_aaaaaaaa', detached: false, isMain: false, locked: false, path: '/repo/.worktrees/t_aaaaaaaa' },
{ branch: 'wt/t_bbbbbbbb', detached: false, isMain: false, locked: false, path: '/repo/.worktrees/t_bbbbbbbb' }
]
expect(mergeRepoWorktreeGroups(repo, discovered).map(g => g.label)).toEqual(['main'])
})
it('does not duplicate a lane already present from the backend (by id/path)', () => {
const repo = {
id: '/repo',
path: '/repo',
groups: [
lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })
]
}
const discovered: HermesGitWorktree[] = [
{ branch: 'main', detached: false, isMain: true, locked: false, path: '/repo' }
]
const merged = mergeRepoWorktreeGroups(repo, discovered)
expect(merged).toHaveLength(1)
// The backend lane keeps its session rows; the enhancer left it untouched.
expect(merged[0].sessions).toHaveLength(1)
})
it('is a no-op when git worktree list is unavailable (remote backend)', () => {
const groups = [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })]
expect(mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups }, undefined).map(g => g.label)).toEqual(['main'])
})
it('does not add a second "main" for a linked worktree checked out on main', () => {
const groups = [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })]
const discovered: HermesGitWorktree[] = [
{ branch: 'main', detached: false, isMain: false, locked: false, path: '/repo/.worktrees/main-mirror' }
]
expect(mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups }, discovered).filter(g => g.label === 'main')).toHaveLength(1)
})
it('surfaces a user-named "New worktree" under .worktrees/ as its own lane', () => {
const discovered: HermesGitWorktree[] = [
{ branch: 'hermes/test-gui-stuff', detached: false, isMain: false, locked: false, path: '/repo/.worktrees/test-gui-stuff' }
]
const merged = mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups: [] }, discovered)
expect(merged.map(g => g.label)).toContain('hermes/test-gui-stuff')
})
it('relabels a dir-named linked worktree lane to its live checked-out branch', () => {
// Backend labels the lane by the worktree dir (`hermes-agent-ci`); the live
// `git worktree list` says HEAD there is `bb/ci-affected-only` → branch wins.
const repo = {
id: '/repo',
path: '/repo',
groups: [
lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] }),
lane({
id: '/repo-ci',
label: 'hermes-agent-ci',
isMain: false,
path: '/repo-ci',
sessions: [makeSession('/repo-ci')]
})
]
}
const discovered: HermesGitWorktree[] = [
{ branch: 'main', detached: false, isMain: true, locked: false, path: '/repo' },
{ branch: 'bb/ci-affected-only', detached: false, isMain: false, locked: false, path: '/repo-ci' }
]
const merged = mergeRepoWorktreeGroups(repo, discovered)
const ci = merged.find(g => g.id === '/repo-ci')
expect(ci?.label).toBe('bb/ci-affected-only')
// The relabel is label-only — the lane keeps its id, path, and sessions.
expect(ci?.path).toBe('/repo-ci')
expect(ci?.sessions).toHaveLength(1)
})
it('re-anchors a lane whose path drifted from git truth back to its branch path', () => {
// The reported bug: a lane is correctly labeled by its branch (`bb/attempts`)
// but its stored PATH points at a stale/old worktree dir. git pins a branch
// to exactly one worktree, so the lane must follow the branch's real path —
// otherwise "reveal in Finder" opens a completely different worktree.
const repo = {
id: '/repo',
path: '/repo',
groups: [
lane({
id: '/repo/.worktrees/attempts',
label: 'bb/attempts',
isMain: false,
path: '/repo/.worktrees/attempts',
sessions: [makeSession('/repo/.worktrees/attempts')]
})
]
}
// git now has `bb/attempts` at a sibling dir, not the stale `.worktrees` one.
const discovered: HermesGitWorktree[] = [
{ branch: 'bb/attempts', detached: false, isMain: false, locked: false, path: '/repo-pr-attempts' }
]
const merged = mergeRepoWorktreeGroups(repo, discovered)
const attempts = merged.filter(g => g.label === 'bb/attempts')
// Exactly one lane, re-pointed at git's real path (label preserved, sessions
// preserved), and NO leftover lane on the stale path.
expect(attempts).toHaveLength(1)
expect(attempts[0].path).toBe('/repo-pr-attempts')
expect(attempts[0].sessions).toHaveLength(1)
expect(merged.some(g => g.path === '/repo/.worktrees/attempts')).toBe(false)
})
it('collapses a re-anchored lane onto the real lane that already holds that path', () => {
// A stale lane (branch label, wrong path) AND the real worktree lane both
// exist. Re-anchoring the stale one onto git's path must not leave a twin —
// keep the richer (more sessions) lane.
const repo = {
id: '/repo',
path: '/repo',
groups: [
lane({ id: 'stale', label: 'bb/feature', isMain: false, path: '/repo/.worktrees/old', sessions: [] }),
lane({
id: '/repo-feature',
label: 'bb/feature',
isMain: false,
path: '/repo-feature',
sessions: [makeSession('/repo-feature'), makeSession('/repo-feature')]
})
]
}
const discovered: HermesGitWorktree[] = [
{ branch: 'bb/feature', detached: false, isMain: false, locked: false, path: '/repo-feature' }
]
const merged = mergeRepoWorktreeGroups(repo, discovered)
const feature = merged.filter(g => g.path === '/repo-feature')
expect(feature).toHaveLength(1)
expect(feature[0].sessions).toHaveLength(2)
expect(merged.some(g => g.path === '/repo/.worktrees/old')).toBe(false)
})
it('keeps the dir label for a detached-HEAD worktree (no branch to show)', () => {
const repo = {
id: '/repo',
path: '/repo',
groups: [
lane({ id: '/repo-ci', label: 'repo-ci', isMain: false, path: '/repo-ci', sessions: [makeSession('/repo-ci')] })
]
}
const discovered: HermesGitWorktree[] = [
{ branch: null, detached: true, isMain: false, locked: false, path: '/repo-ci' }
]
expect(mergeRepoWorktreeGroups(repo, discovered).find(g => g.id === '/repo-ci')?.label).toBe('repo-ci')
})
it('collapses the main checkout into one home lane labeled by the live branch (off-trunk)', () => {
const repo = {
id: '/repo',
path: '/repo',
groups: [
lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })
]
}
// The repo root is switched to a feature branch. The historical "main"
// sessions fold into ONE home lane labeled by the live branch — no stale
// "main" lane lingering beside it.
const discovered: HermesGitWorktree[] = [
{ branch: 'some-feature', detached: false, isMain: true, locked: false, path: '/repo' }
]
const merged = mergeRepoWorktreeGroups(repo, discovered)
const home = merged.find(g => g.isHome)
expect(merged.filter(g => g.isMain)).toHaveLength(1)
expect(home?.label).toBe('some-feature')
expect(home?.path).toBe('/repo')
expect(home?.sessions).toHaveLength(1)
expect(merged.some(g => g.label === 'main')).toBe(false)
})
it('labels the home lane "main" (still home-flagged) when the root is on trunk', () => {
const repo = {
id: '/repo',
path: '/repo',
groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })]
}
const discovered: HermesGitWorktree[] = [{ branch: 'main', detached: false, isMain: true, locked: false, path: '/repo' }]
const home = mergeRepoWorktreeGroups(repo, discovered).find(g => g.isHome)
expect(home?.label).toBe('main')
expect(home?.isHome).toBe(true)
})
it('folds multiple historical main-checkout branch lanes into the single live home lane', () => {
const repo = {
id: '/repo',
path: '/repo',
groups: [
lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo', { id: 'a' })] }),
lane({ id: '/repo::branch::old', label: 'old-feature', isMain: true, path: '/repo', sessions: [makeSession('/repo', { id: 'b' })] })
]
}
const discovered: HermesGitWorktree[] = [{ branch: 'bb/live', detached: false, isMain: true, locked: false, path: '/repo' }]
const merged = mergeRepoWorktreeGroups(repo, discovered)
const home = merged.find(g => g.isHome)
expect(merged.filter(g => g.isMain)).toHaveLength(1)
expect(home?.label).toBe('bb/live')
expect(home?.sessions.map(s => s.id).sort()).toEqual(['a', 'b'])
})
it('leaves main lanes untouched on a remote backend (no git probe)', () => {
const repo = { id: '/repo', path: '/repo', groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })] }
// No discovered worktrees → no live branch truth → backend label stands.
const merged = mergeRepoWorktreeGroups(repo, undefined)
expect(merged.map(g => g.label)).toEqual(['main'])
expect(merged[0].isHome).toBeFalsy()
})
})
const makeProject = (id: string, folders: string[]): ProjectInfo => ({
archived: false,
board_slug: null,
color: null,
created_at: 0,
description: null,
folders: folders.map((path, i) => ({ added_at: 0, is_primary: i === 0, label: null, path })),
icon: null,
id,
name: id,
primary_path: folders[0] ?? null,
slug: id
})
const projectNode = (over: Partial<SidebarProjectTree> & Pick<SidebarProjectTree, 'id'>): SidebarProjectTree => ({
label: over.id,
path: over.id,
repos: [],
sessionCount: 0,
...over
})
describe('liveSessionProjectId', () => {
it('maps a brand-new (unpersisted) session to its auto project (the repo root)', () => {
expect(liveSessionProjectId(makeSession('/www/app'), [])).toBe('/www/app')
})
it('routes a session under an explicit project folder to that project', () => {
const id = liveSessionProjectId(makeSession('/www/app/src', { git_repo_root: '/www/app', git_branch: 'feat' }), [
makeProject('p_app', ['/www/app'])
])
expect(id).toBe('p_app')
})
it('skips cwd-less, kanban-task, and out-of-tree (sibling) worktree sessions', () => {
expect(liveSessionProjectId(makeSession(null), [])).toBeNull()
// Kanban task worktree → folds into the kanban bucket, not a project preview.
expect(liveSessionProjectId(makeSession('/repo/.worktrees/t_aaaaaaaa'), [])).toBeNull()
// Sibling worktree OUTSIDE the repo root → project can't be derived from the row.
expect(liveSessionProjectId(makeSession('/elsewhere/wt', { git_repo_root: '/repo' }), [])).toBeNull()
})
it('places an in-tree worktree session under its repo project (the root is in the path)', () => {
// "Convert a branch" / "new worktree" land at `<repoRoot>/.worktrees/<slug>`,
// so they belong to the same auto project as the repo root and must show in
// the overview at once, not wait for the next backend refresh.
expect(
liveSessionProjectId(makeSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [])
).toBe('/www/app')
})
it('routes an in-tree worktree session to the owning explicit project', () => {
const id = liveSessionProjectId(makeSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [
makeProject('p_app', ['/www/app'])
])
expect(id).toBe('p_app')
})
})
describe('overlayLiveLanes', () => {
it('injects a live session into the matching main lane instantly', () => {
const project = projectNode({
id: '/www/app',
isAuto: true,
repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }]
})
const live = [makeSession('/www/app', { id: 'fresh', git_branch: 'main' })]
const overlaid = overlayLiveLanes(project, live)
const lane = overlaid.repos[0].groups.find(g => g.label === 'main')
expect(lane?.sessions.map(session => session.id)).toContain('fresh')
expect(overlaid.sessionCount).toBe(1)
})
it('injects a session created in a fresh worktree into that worktree lane (no git_repo_root yet)', () => {
// The brand-new session row has only a cwd — no git_repo_root. The entered
// project knows its repo root, so the worktree session still lands in its
// own lane (not kanban, not skipped) optimistically.
const project = projectNode({
id: '/www/app',
isAuto: true,
repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }]
})
const live = [makeSession('/www/app/.worktrees/baby', { id: 'fresh' })]
const overlaid = overlayLiveLanes(project, live)
const lane = overlaid.repos[0].groups.find(g => g.id === '/www/app/.worktrees/baby')
expect(lane?.label).toBe('baby')
expect(lane?.sessions.map(s => s.id)).toEqual(['fresh'])
})
it('folds a kanban-task worktree session into the kanban lane', () => {
const project = projectNode({
id: '/www/app',
isAuto: true,
repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }]
})
const live = [makeSession('/www/app/.worktrees/t_abc12345', { id: 'k' })]
const overlaid = overlayLiveLanes(project, live)
const lane = overlaid.repos[0].groups.find(g => g.isKanban)
expect(lane?.id).toBe('/www/app::kanban')
expect(lane?.sessions.map(s => s.id)).toEqual(['k'])
})
it('does not duplicate a session already present in a backend lane', () => {
const existing = makeSession('/www/app', { id: 'dup', git_branch: 'main' })
const project = projectNode({
id: '/www/app',
repos: [
{
id: '/www/app',
label: 'app',
path: '/www/app',
sessionCount: 1,
groups: [lane({ id: '/www/app::branch::main', label: 'main', isMain: true, path: '/www/app', sessions: [existing] })]
}
]
})
const overlaid = overlayLiveLanes(project, [existing])
expect(overlaid.repos[0].groups.flatMap(g => g.sessions.map(s => s.id))).toEqual(['dup'])
})
it('adds a new session to an existing worktree lane keyed by a divergent id (matches by path)', () => {
// Backend keyed the worktree lane off a branch-style id (no live git probe),
// but the lane PATH is the worktree dir. A new session under that worktree
// must join the existing lane, not spawn a twin.
const existing = makeSession('/www/app/.worktrees/baby', { id: 'old' })
const project = projectNode({
id: '/www/app',
repos: [
{
id: '/www/app',
label: 'app',
path: '/www/app',
sessionCount: 1,
groups: [
lane({ id: '/www/app::branch::baby', label: 'baby', path: '/www/app/.worktrees/baby', sessions: [existing] })
]
}
]
})
const fresh = makeSession('/www/app/.worktrees/baby', { id: 'fresh' })
const overlaid = overlayLiveLanes(project, [existing, fresh])
const lanes = overlaid.repos[0].groups.filter(g => g.path === '/www/app/.worktrees/baby')
expect(lanes).toHaveLength(1)
expect(lanes[0].sessions.map(s => s.id).sort()).toEqual(['fresh', 'old'])
})
it('places a session into an out-of-tree (sibling) worktree lane by its path', () => {
// `hermes-agent-ci` is a linked worktree living BESIDE the repo, not under
// it — repo-root nesting fails, but the existing lane carries its real path.
const existing = makeSession('/www/app-ci', { id: 'old' })
const project = projectNode({
id: '/www/app',
repos: [
{
id: '/www/app',
label: 'app',
path: '/www/app',
sessionCount: 1,
groups: [
lane({ id: '/www/app::branch::main', label: 'main', isMain: true, path: '/www/app', sessions: [] }),
lane({ id: '/www/app-ci', label: 'app-ci', path: '/www/app-ci', sessions: [existing] })
]
}
]
})
const fresh = makeSession('/www/app-ci', { id: 'fresh' })
const overlaid = overlayLiveLanes(project, [existing, fresh])
const ci = overlaid.repos[0].groups.find(g => g.path === '/www/app-ci')
const main = overlaid.repos[0].groups.find(g => g.label === 'main')
expect(ci?.sessions.map(s => s.id).sort()).toEqual(['fresh', 'old'])
expect(main?.sessions ?? []).toHaveLength(0)
})
it('places into a visual-only discovered worktree lane after merge', () => {
const discovered = [{ path: '/www/app-retry', branch: 'bb/ci-install-retry', isMain: false, detached: false, locked: false }]
const groups = mergeRepoWorktreeGroups({ id: '/www/app', path: '/www/app', groups: [] }, discovered)
const project = projectNode({
id: '/www/app',
repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups }]
})
const fresh = makeSession('/www/app-retry', { id: 'fresh' })
const overlaid = overlayLiveLanes(project, [fresh])
const lane = overlaid.repos[0].groups.find(g => g.path === '/www/app-retry')
expect(lane?.sessions.map(s => s.id)).toEqual(['fresh'])
})
it('evicts a deleted/archived snapshot row (and drops the lane once empty)', () => {
const a = makeSession('/www/app', { id: 'keep', git_branch: 'main' })
const b = makeSession('/www/app/.worktrees/baby', { id: 'gone' })
const project = projectNode({
id: '/www/app',
repos: [
{
id: '/www/app',
label: 'app',
path: '/www/app',
sessionCount: 2,
groups: [
lane({ id: '/www/app::branch::main', label: 'main', isMain: true, path: '/www/app', sessions: [a] }),
lane({ id: '/www/app/.worktrees/baby', label: 'baby', path: '/www/app/.worktrees/baby', sessions: [b] })
]
}
]
})
// No live rows (both deleted from $sessions); only 'gone' is tombstoned.
const overlaid = overlayLiveLanes(project, [a], new Set(['gone']))
expect(overlaid.repos[0].groups.map(g => g.id)).toEqual(['/www/app::branch::main'])
expect(overlaid.repos[0].groups[0].sessions.map(s => s.id)).toEqual(['keep'])
expect(overlaid.sessionCount).toBe(1)
})
})
describe('overlayLivePreviews', () => {
it('merges live sessions into a project preview, live first, capped to the limit', () => {
const project = projectNode({
id: '/www/app',
previewSessions: [makeSession('/www/app', { id: 'old', started_at: 1, last_active: 1 })]
})
const live = [makeSession('/www/app', { id: 'fresh', started_at: 99, last_active: 99 })]
const previews = overlayLivePreviews([project], live, [], 3)
expect(previews['/www/app'].map(s => s.id)).toEqual(['fresh', 'old'])
})
it('evicts a deleted session from a project preview (snapshot + live)', () => {
const project = projectNode({
id: '/www/app',
previewSessions: [
makeSession('/www/app', { id: 'gone', started_at: 5, last_active: 5 }),
makeSession('/www/app', { id: 'old', started_at: 1, last_active: 1 })
]
})
const previews = overlayLivePreviews([project], [], [], 3, new Set(['gone']))
expect(previews['/www/app'].map(s => s.id)).toEqual(['old'])
})
})

View file

@ -0,0 +1,582 @@
import type { HermesGitWorktree } from '@/global'
import type { ProjectInfo, SessionInfo } from '@/hermes'
// Session grouping is now computed authoritatively on the backend
// (`tui_gateway/project_tree.py`, exposed via `projects.tree` /
// `projects.project_sessions`). The desktop is a thin renderer: this module
// only holds the render contract (the three tree interfaces) plus a couple of
// pure helpers and the VISUAL-ONLY worktree enhancer that injects empty lanes
// from `git worktree list`. It never decides session membership.
export interface SidebarSessionGroup {
id: string
label: string
path: null | string
sessions: SessionInfo[]
// Profile color for the ALL-profiles view; absent for workspace groups.
color?: null | string
// True when this group is a repo's main checkout (vs a linked worktree).
isMain?: boolean
// True for the repo's primary ("home") checkout lane — the single lane that
// collapses all main-checkout sessions, labeled by the worktree's LIVE branch
// (defaulting to `main`). Renders a home glyph and pins to the top.
isHome?: boolean
// True for the synthetic lane that collapses all of a repo's kanban task
// worktrees (`<repo>/.worktrees/t_*`) into one row, so a heavy board doesn't
// spray hundreds of throwaway branch lanes across the sidebar.
isKanban?: boolean
loadingMore?: boolean
mode?: 'profile' | 'source' | 'workspace'
onLoadMore?: () => void
sourceId?: string
totalCount?: number
}
/** A repo node: holds its branch/worktree lanes (`repo -> lane -> sessions`). */
export interface SidebarWorkspaceTree {
id: string
label: string
path: null | string
groups: SidebarSessionGroup[]
sessionCount: number
}
/** A project node: human-named (or repo-derived), holds its repo subtree. */
export interface SidebarProjectTree {
id: string
label: string
path: null | string
color?: null | string
icon?: null | string
archived?: boolean
// A git repo root promoted automatically (not a user-created projects.db row).
// Deletable = dismissable.
isAuto?: boolean
// The synthetic "No project" bucket for cwd-less sessions.
isNoProject?: boolean
repos: SidebarWorkspaceTree[]
sessionCount: number
// Max activity timestamp across the project's sessions (overview sort key).
lastActive?: number
// Up to N most-recent sessions for the overview preview (set by `projects.tree`).
previewSessions?: SessionInfo[]
}
/** Path split into segments, ignoring trailing slashes and mixed separators. */
const segments = (path: string): string[] =>
path
.replace(/[/\\]+$/, '')
.split(/[/\\]/)
.filter(Boolean)
/** A path with trailing separators stripped, for stable equality checks. */
const normalizePath = (path: null | string | undefined): string => (path ?? '').replace(/[/\\]+$/, '')
/** Last path segment. */
export const baseName = (path: string): string | undefined => segments(path).pop()
// The `.worktrees` dir for a KANBAN-TASK worktree path, else null. Only matches
// task worktrees (`<repo>/.worktrees/t_<hex>`, the `t_…` id kanban_db mints) so
// the many ephemeral task worktrees collapse into one lane — while user-named
// "New worktree" dirs (`<repo>/.worktrees/<slug>`) stay as their own lanes.
const KANBAN_DIR_RE = /^(.*[/\\]\.worktrees)[/\\]t_[0-9a-f]+[/\\]?$/
export function kanbanWorktreeDir(path: string): null | string {
return path.match(KANBAN_DIR_RE)?.[1] ?? null
}
/** Label for a main-checkout lane whose session recorded no branch. */
export const DEFAULT_BRANCH_LABEL = 'main'
/** The one definition of a main-checkout lane id (must match the backend tree). */
export const branchLaneId = (repoRoot: string, branch?: string): string =>
`${repoRoot}::branch::${(branch ?? '').trim()}`
/** A session's recency stamp (last activity, falling back to creation). */
export const sessionRecency = (session: SessionInfo): number => session.last_active || session.started_at || 0
/** Default-branch names that pin to the top and read as the repo's trunk. */
const TRUNK_BRANCHES = new Set(['main', 'master', 'trunk', 'develop'])
const isTrunkLane = (group: SidebarSessionGroup): boolean =>
Boolean(group.isMain) && TRUNK_BRANCHES.has(group.label.toLowerCase())
/** A lane's recency = its most-recently-active session (empty lanes sink). */
const laneActivity = (group: SidebarSessionGroup): number =>
group.sessions.reduce((max, session) => Math.max(max, sessionRecency(session)), 0)
// Lane tiers (low sorts first): the repo's primary ("home") checkout pins above
// everything (it's "where you are", labeled by its live branch), then trunk,
// then ordinary branches/worktrees, then the kanban aggregate.
const laneRank = (group: SidebarSessionGroup): number =>
group.isHome ? 0 : isTrunkLane(group) ? 1 : group.isKanban ? 3 : 2
/**
* Sort by tier (home trunk branches/worktrees kanban); within a tier, by
* most-recent activity (empty lanes fall last), label as the tiebreak.
*/
function compareWorktreeGroups(a: SidebarSessionGroup, b: SidebarSessionGroup): number {
const byRank = laneRank(a) - laneRank(b)
if (byRank !== 0) {
return byRank
}
const byActivity = laneActivity(b) - laneActivity(a)
return byActivity || a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
}
export function sortWorktreeGroups(groups: SidebarSessionGroup[]): SidebarSessionGroup[] {
return [...groups].sort(compareWorktreeGroups)
}
/**
* VISUAL enhancer only: inject empty lanes from a live `git worktree list` so a
* repo shows its branches/worktrees even when they have no Hermes sessions yet.
* The repo's real session lanes already come fully built from the backend
* (`projects.project_sessions`); this never adds or moves session rows, and it
* degrades to a no-op on remote backends (where the Electron probe returns
* nothing). Lanes already present (by id/path) are left untouched.
*/
export function mergeRepoWorktreeGroups(
repo: Pick<SidebarWorkspaceTree, 'groups' | 'id' | 'path'>,
discoveredWorktrees?: HermesGitWorktree[]
): SidebarSessionGroup[] {
// Branch-primary labels: a linked worktree's identity in every git UI (VS
// Code, JetBrains, lazygit, …) is its CHECKED-OUT BRANCH, not the directory it
// happens to live in. The backend labels these lanes by dir/slug; relabel them
// to the live branch from `git worktree list` so the sidebar matches the
// composer's branch strip. Detached worktrees (no branch) keep their dir label.
const liveBranchByPath = new Map<string, string>()
// Inverse: branch → its ONE live worktree path. git guarantees a branch is
// checked out in at most one worktree, so this mapping is a function and can
// re-anchor a lane whose stored path has drifted from git truth.
const livePathByBranch = new Map<string, string>()
for (const worktree of discoveredWorktrees ?? []) {
const wtPath = normalizePath(worktree.path)
const branch = worktree.branch?.trim()
if (wtPath && branch && !worktree.detached) {
liveBranchByPath.set(wtPath, branch)
livePathByBranch.set(branch.toLowerCase(), worktree.path.trim())
}
}
// The primary ("home") checkout's LIVE branch. A repo dir is only ever on ONE
// branch, so every main-checkout session lane (historical branches over the
// same root path) collapses into a single home lane labeled by this live
// branch, defaulting to `main`. Known only when the local git probe ran;
// remote backends keep the backend's recorded-branch main lane untouched.
const mainWorktree = (discoveredWorktrees ?? []).find(w => w.isMain)
const homeBranch = mainWorktree && !mainWorktree.detached ? mainWorktree.branch?.trim() || DEFAULT_BRANCH_LABEL : ''
// Reconcile a LINKED worktree lane against git truth so its label AND path
// describe the SAME worktree. Two repair directions:
// 1. Path git knows → relabel to that path's live branch (git UIs identify a
// worktree by its checked-out branch, not the dir it lives in).
// 2. Path git DOESN'T know but the label IS a live branch → the lane's path
// has gone stale; re-anchor it to that branch's real path, else "reveal"
// opens a different, stale checkout. The home checkout is folded
// separately (below), never here.
const reconcile = (group: SidebarSessionGroup): SidebarSessionGroup => {
if (group.isMain || group.isKanban) {
return group
}
const branchForPath = liveBranchByPath.get(normalizePath(group.path))
if (branchForPath) {
return branchForPath !== group.label ? { ...group, label: branchForPath } : group
}
const livePath = livePathByBranch.get(group.label.trim().toLowerCase())
if (livePath && normalizePath(livePath) !== normalizePath(group.path)) {
return { ...group, id: livePath, path: livePath }
}
return group
}
const dedupeById = (sessions: SessionInfo[]): SessionInfo[] => {
const byId = new Map<string, SessionInfo>()
for (const session of sessions) {
byId.set(session.id, byId.get(session.id) ?? session)
}
return [...byId.values()]
}
// Fold every main-checkout lane into one home lane labeled by the live branch
// (the root dir is only ever on one branch); reconcile the linked worktrees.
// Always shown, even with no sessions on the current branch yet. Remote
// backends (no probe → no homeBranch) keep their main lanes untouched.
const mainGroups = repo.groups.filter(group => group.isMain)
const reconciled = repo.groups.filter(group => !group.isMain).map(reconcile)
if (homeBranch) {
reconciled.push({
id: branchLaneId(repo.id, homeBranch),
label: homeBranch,
path: repo.path,
isMain: true,
isHome: true,
sessions: dedupeById(mainGroups.flatMap(group => group.sessions))
})
} else {
reconciled.push(...mainGroups)
}
// Collapse any duplicate a re-anchor produced (a stale lane re-pointed onto a
// path a real lane already holds) — keep the richer (more sessions) lane.
const byPath = new Map<string, SidebarSessionGroup>()
const merged: SidebarSessionGroup[] = []
for (const group of reconciled) {
const key = !group.isMain && group.path ? normalizePath(group.path) : ''
const existing = key ? byPath.get(key) : undefined
if (existing) {
if (group.sessions.length > existing.sessions.length) {
merged[merged.indexOf(existing)] = group
byPath.set(key, group)
}
continue
}
if (key) {
byPath.set(key, group)
}
merged.push(group)
}
const seenIds = new Set(merged.map(group => group.id))
const seenPaths = new Set(merged.map(group => group.path).filter((path): path is string => Boolean(path)))
// Dedupe by branch label too: a branch shows once even if it's checked out in
// a linked worktree AND already has a session lane.
const seenLabels = new Set(merged.map(group => group.label.toLowerCase()))
for (const worktree of discoveredWorktrees ?? []) {
const wtPath = worktree.path?.trim()
if (!wtPath) {
continue
}
// The home checkout is already the collapsed home lane (above).
if (worktree.isMain && homeBranch) {
continue
}
// Kanban task worktrees never get their own lane — they fold into the
// session-derived `::kanban` bucket. Listing every `git worktree list` entry
// here is exactly what blew the sidebar up to hundreds of empty rows.
if (!worktree.isMain && kanbanWorktreeDir(wtPath)) {
continue
}
const label = (worktree.isMain ? worktree.branch?.trim() || DEFAULT_BRANCH_LABEL : worktree.branch?.trim()) || baseName(wtPath) || wtPath
const id = worktree.isMain ? branchLaneId(repo.id, label) : wtPath
const alreadySeen =
seenIds.has(id) || seenLabels.has(label.toLowerCase()) || (!worktree.isMain && seenPaths.has(wtPath))
if (alreadySeen) {
continue
}
merged.push({ id, isMain: worktree.isMain, label, path: wtPath, sessions: [] })
seenIds.add(id)
seenPaths.add(wtPath)
seenLabels.add(label.toLowerCase())
}
return sortWorktreeGroups(merged)
}
// ── Live session overlay ─────────────────────────────────────────────────────
// The backend tree is a snapshot (sessions with >=1 message, refreshed on a
// turn boundary). For parity with the flat Recents list — instant insertion of
// a freshly-created session and the live "working" arc — we overlay the live
// `$sessions` store onto the tree at render time. This is ADDITIVE only: the
// backend still owns membership, structure, counts, and history. The overlay
// just places rows already present in `$sessions` into the project/lane the
// backend would put them in, using the same id scheme. Worktree/kanban folding
// needs the backend common-root probe, so those rows are left for the next
// tree refresh; the common case (a new main-checkout session) overlays here.
/** True when `target` equals `folder` or is nested under it (segment-wise). */
function isPathUnder(folder: string, target: string): boolean {
const f = segments(folder)
const t = segments(target)
if (!f.length || f.length > t.length) {
return false
}
return f.every((seg, i) => seg === t[i])
}
/**
* The project a live session belongs to (overview membership) explicit project
* by longest-prefix folder, else the repo root (the auto-project id). An IN-TREE
* linked worktree (`<repoRoot>/.worktrees/<slug>`) belongs to the SAME project as
* its repo root (the root is right there in the path), so a freshly-created
* worktree session e.g. from "convert a branch" / "new worktree" surfaces in
* the overview at once instead of waiting for the next backend refresh. Returns
* null only for sessions we genuinely can't place from the row alone: cwd-less,
* kanban-task worktrees (they fold into the kanban bucket), or a worktree that
* lives OUTSIDE the repo root (a sibling dir whose project can't be derived).
*/
export function liveSessionProjectId(session: SessionInfo, explicitProjects: ProjectInfo[]): null | string {
const cwd = (session.cwd || '').trim()
if (!cwd || kanbanWorktreeDir(cwd)) {
return null
}
// No persisted repo root yet (brand-new session) → the cwd is the root.
const repoRoot = (session.git_repo_root || '').trim() || cwd
const underRepo = cwd === repoRoot || cwd.startsWith(`${repoRoot}/`) || cwd.startsWith(`${repoRoot}\\`)
if (!underRepo) {
return null
}
let projectId = ''
let bestLen = -1
for (const project of explicitProjects) {
if (project.archived) {
continue
}
for (const folder of project.folders) {
if (isPathUnder(folder.path, cwd) || isPathUnder(folder.path, repoRoot)) {
const len = segments(folder.path).length
if (len > bestLen) {
bestLen = len
projectId = project.id
}
}
}
}
return projectId || repoRoot
}
const upsertSession = (rows: SessionInfo[], session: SessionInfo): SessionInfo[] =>
[session, ...rows.filter(row => row.id !== session.id)].sort((a, b) => b.started_at - a.started_at)
/**
* The lane a live session belongs to WITHIN a known repo root, by path the
* entered project already knows its repo roots, so we don't need the session's
* (often-unset, on a fresh row) git_repo_root. Mirrors the backend's lane ids:
* main checkout -> branch lane, `.worktrees/t_<hex>` -> kanban, any other
* `.worktrees/<slug>` -> that worktree's own lane.
*/
function liveLaneForRepo(repoRoot: string, session: SessionInfo): null | SidebarSessionGroup {
const cwd = (session.cwd || '').trim()
if (!cwd || !isPathUnder(repoRoot, cwd)) {
return null
}
const wt = cwd.match(/^(.*[/\\]\.worktrees)[/\\]([^/\\]+)/)
if (wt) {
const [worktreeRoot, worktreesDir, slug] = [wt[0], wt[1], wt[2]]
return /^t_[0-9a-f]+$/.test(slug)
? { id: `${repoRoot}::kanban`, isKanban: true, isMain: false, label: 'kanban', path: worktreesDir, sessions: [] }
: { id: worktreeRoot, isMain: false, label: slug, path: worktreeRoot, sessions: [] }
}
const branch = (session.git_branch || '').trim() || DEFAULT_BRANCH_LABEL
return { id: branchLaneId(repoRoot, branch), isMain: true, label: branch, path: repoRoot, sessions: [] }
}
const NO_REMOVED: ReadonlySet<string> = new Set()
/**
* Reconcile ONE repo's lanes against the live `$sessions` cache: evict
* deleted/archived rows (`removed`) and inject freshly-created ones, so a lane
* mutates exactly like the flat Recents list. The backend snapshot stays the
* datasource for structure and off-page history; this is the optimistic layer
* on top (Apollo-style), reconciled away on the next snapshot refresh. Returns
* the same repo ref when nothing changes (memo-stable).
*/
export function overlayRepoLanes(
repo: SidebarWorkspaceTree,
live: SessionInfo[],
removed: ReadonlySet<string> = NO_REMOVED
): SidebarWorkspaceTree {
const repoRoot = normalizePath(repo.path)
let changed = false
// Snapshot lanes minus anything the user just deleted/archived.
const lanes = repo.groups.map(g => {
if (!removed.size) {
return { ...g, sessions: [...g.sessions] }
}
const kept = g.sessions.filter(s => !removed.has(s.id))
changed ||= kept.length !== g.sessions.length
return { ...g, sessions: kept }
})
for (const session of live) {
const cwd = (session.cwd || '').trim()
if (removed.has(session.id) || !cwd) {
continue
}
// (1) Join an EXISTING worktree lane by its own path. A linked worktree can
// live anywhere on disk (often a repo sibling, e.g. `repo-ci`), so nesting
// under the repo root isn't reliable — but the lane carries its real dir.
// Longest match wins; skip the root lane so an in-tree `.worktrees/<slug>`
// session isn't swallowed by main.
let lane: SidebarSessionGroup | undefined
let bestLen = -1
for (const g of lanes) {
const lanePath = normalizePath(g.path)
if (!lanePath || lanePath === repoRoot || !isPathUnder(lanePath, cwd)) {
continue
}
const len = segments(lanePath).length
if (len > bestLen) {
bestLen = len
lane = g
}
}
// (2) Else place under the repo root via a computed lane (main / branch /
// in-tree `.worktrees` / kanban). Match by id, then path (the backend may
// key a worktree lane off the git-probed root OR a branch-style id), then
// the main-lane label; create it when the snapshot lacked it.
if (!lane) {
const placed = repo.path ? liveLaneForRepo(repo.path, session) : null
if (!placed) {
continue
}
const placedPath = normalizePath(placed.path)
lane =
lanes.find(g => g.id === placed.id) ??
(placed.isMain ? lanes.find(g => g.isMain && g.label.toLowerCase() === placed.label.toLowerCase()) : undefined) ??
(!placed.isMain && placedPath ? lanes.find(g => normalizePath(g.path) === placedPath) : undefined)
if (!lane) {
lane = { ...placed, sessions: [] }
lanes.push(lane)
}
}
lane.sessions = upsertSession(lane.sessions, session)
changed = true
}
if (!changed) {
return repo
}
// Drop lanes emptied by eviction (the server only emits non-empty lanes; the
// git-worktree enhancer re-adds any still-real worktree as an empty lane).
const groups = sortWorktreeGroups(lanes.filter(g => g.sessions.length > 0))
return { ...repo, groups, sessionCount: groups.reduce((n, g) => n + g.sessions.length, 0) }
}
/** Project-level overlay: {@link overlayRepoLanes} across every repo subtree. */
export function overlayLiveLanes(
project: SidebarProjectTree,
live: SessionInfo[],
removed: ReadonlySet<string> = NO_REMOVED
): SidebarProjectTree {
let changed = false
const repos = project.repos.map(repo => {
const next = overlayRepoLanes(repo, live, removed)
changed ||= next !== repo
return next
})
if (!changed) {
return project
}
return { ...project, repos, sessionCount: repos.reduce((n, repo) => n + repo.sessionCount, 0) }
}
/** Merge live sessions into per-project overview previews, keyed by project path. */
export function overlayLivePreviews(
projects: SidebarProjectTree[],
live: SessionInfo[],
explicitProjects: ProjectInfo[],
limit: number,
removed: ReadonlySet<string> = new Set()
): Record<string, SessionInfo[]> {
const byProject = new Map<string, SessionInfo[]>()
for (const session of live) {
if (removed.has(session.id)) {
continue
}
const projectId = liveSessionProjectId(session, explicitProjects)
if (!projectId) {
continue
}
const arr = byProject.get(projectId) ?? []
arr.push(session)
byProject.set(projectId, arr)
}
const out: Record<string, SessionInfo[]> = {}
for (const node of projects) {
if (!node.path) {
continue
}
const liveRows = byProject.get(node.id) ?? []
const base = (node.previewSessions ?? []).filter(session => !removed.has(session.id))
if (!liveRows.length && !base.length) {
continue
}
// Live rows take precedence (fresher title/activity/working state).
const map = new Map<string, SessionInfo>()
for (const session of [...liveRows, ...base]) {
if (!map.has(session.id)) {
map.set(session.id, session)
}
}
out[node.path] = [...map.values()].sort((a, b) => sessionRecency(b) - sessionRecency(a)).slice(0, limit)
}
return out
}

View file

@ -0,0 +1,362 @@
import type * as React from 'react'
import { useCallback, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { SanitizedInput } from '@/components/ui/sanitized-input'
import type { HermesGitBranch } from '@/global'
import { useI18n } from '@/i18n'
import { gitRef } from '@/lib/sanitize'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { copyPath, listRepoBranches, revealPath, startWorkInRepo } from '@/store/projects'
import { SidebarCount, SidebarRowLead } from '../chrome'
// Branch/worktree labels routinely share a long prefix (`bb/coding-context-…`),
// so plain end-truncation (`truncate`) hides exactly the suffix that tells two
// lanes apart — both render as "bb/coding-context…". Keep the tail pinned and
// ellipsize the HEAD instead, so `…context-facts-rpc` and `…context-persona`
// stay distinguishable. Falls back to whole-string for short labels.
function LaneLabel({ label, title }: { label: string; title?: string }) {
const tailLen = Math.min(14, Math.floor(label.length / 2))
const head = label.slice(0, label.length - tailLen)
const tail = label.slice(label.length - tailLen)
return (
<span className="flex min-w-0" title={title}>
<span className="truncate">{head}</span>
<span className="shrink-0 whitespace-pre">{tail}</span>
</span>
)
}
// "+" 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>
)
}
// Reveals the next page of already-loaded rows within a workspace/worktree.
export function WorkspaceShowMoreButton({ count, label, onClick }: { count: number; label: string; onClick: () => void }) {
const { t } = useI18n()
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>
)
}
// Per-worktree actions (linked worktree lanes only), mirroring the session row
// and ProjectMenu kebab: reveal in the file manager, copy path, and remove the
// worktree (runs a real `git worktree remove` via the caller's confirm dialog).
export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemove: () => void }) {
const { t } = useI18n()
const p = t.sidebar.projects
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>
<DropdownMenuContent align="end" className="w-48" sideOffset={6}>
<DropdownMenuItem disabled={!path} onSelect={() => void revealPath(path)}>
<Codicon name="folder-opened" size="0.875rem" />
<span>{p.reveal}</span>
</DropdownMenuItem>
<DropdownMenuItem disabled={!path} onSelect={() => void copyPath(path)}>
<Codicon name="copy" size="0.875rem" />
<span>{p.copyPath}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onRemove} variant="destructive">
<Codicon name="trash" size="0.875rem" />
<span>{`${p.removeWorktree}`}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
// "New worktree": prompt for a branch name, then git spins up a fresh worktree
// for that branch under the repo (the lightest way) and we open a new session
// inside it. Naming is explicit — no auto-generated `hermes/work-<ts>` trees.
export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onStarted: (path: string) => void }) {
const { t } = useI18n()
const s = t.sidebar
const p = s.projects
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [pending, setPending] = useState(false)
// "Convert an existing branch into a worktree" sub-mode: the body swaps the
// new-branch name input for a filterable list of the repo's branches.
const [convertMode, setConvertMode] = useState(false)
const [branches, setBranches] = useState<HermesGitBranch[]>([])
const [branchesLoading, setBranchesLoading] = useState(false)
// Pull the repo's branches each time the picker is entered (cheap + bounded),
// so a branch created mid-session shows up.
const loadBranches = useCallback(async () => {
if (!repoPath) {
return
}
setBranchesLoading(true)
try {
setBranches(await listRepoBranches(repoPath))
} catch {
setBranches([])
} finally {
setBranchesLoading(false)
}
}, [repoPath])
const submit = async () => {
const branch = name.trim()
if (pending || !repoPath || !branch) {
return
}
setPending(true)
try {
// Pass the typed value as both the dir slug source and the branch, so the
// branch is exactly what the user named (the dir is slugified git-side).
const result = await startWorkInRepo(repoPath, { branch, name: branch })
if (result) {
onStarted(result.path)
setOpen(false)
setName('')
}
} catch (err) {
notifyError(err, p.startWorkFailed)
} finally {
setPending(false)
}
}
// Check an EXISTING branch out into a fresh worktree (no new branch).
const convert = async (branch: HermesGitBranch) => {
if (pending || !repoPath || !branch) {
return
}
setPending(true)
try {
const result = branch.worktreePath
? { branch: branch.name, path: branch.worktreePath }
: await startWorkInRepo(repoPath, { existingBranch: branch.name })
if (result) {
onStarted(result.path)
setOpen(false)
}
} catch (err) {
notifyError(err, p.startWorkFailed)
} finally {
setPending(false)
}
}
const enterConvert = () => {
setConvertMode(true)
void loadBranches()
}
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={() => {
setConvertMode(false)
setName('')
setOpen(true)
}}
type="button"
>
<Codicon name="git-branch" size="0.75rem" />
</button>
<Dialog onOpenChange={next => !pending && setOpen(next)} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{convertMode ? p.convertBranchTitle : p.newWorktreeTitle}</DialogTitle>
<DialogDescription>{convertMode ? p.convertBranchDesc : p.newWorktreeDesc}</DialogDescription>
</DialogHeader>
{convertMode ? (
<Command
className="rounded-md border border-(--ui-stroke-tertiary)"
filter={(value, search) => (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}
>
<CommandInput autoFocus disabled={pending} placeholder={p.convertBranchPlaceholder} />
<CommandList className="max-h-64">
<CommandEmpty>{branchesLoading ? p.branchesLoading : p.noBranches}</CommandEmpty>
<CommandGroup>
{branches.map(branch => (
<CommandItem
disabled={pending}
key={branch.name}
onSelect={() => void convert(branch)}
value={branch.name}
>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="git-branch" size="0.8rem" />
<span className="truncate">{branch.name}</span>
{branch.checkedOut && (
<span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">
{p.branchCheckedOut}
</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
) : (
<SanitizedInput
autoFocus
disabled={pending}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
void submit()
} else if (event.key === 'Escape') {
setOpen(false)
}
}}
onValueChange={setName}
placeholder={p.branchPlaceholder}
sanitize={gitRef}
value={name}
/>
)}
{convertMode ? (
// The picker is a sub-screen: a single "Cancel" link steps back to
// the new-branch screen (the dialog's own ✕ / Esc still closes it).
<DialogFooter className="sm:justify-start">
<Button
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
disabled={pending}
onClick={() => setConvertMode(false)}
type="button"
variant="link"
>
{t.common.cancel}
</Button>
</DialogFooter>
) : (
<DialogFooter className="sm:justify-between">
{/* Switch into the convert-an-existing-branch picker. */}
<Button
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
disabled={pending}
onClick={enterConvert}
type="button"
variant="link"
>
{p.convertBranchInstead}
</Button>
<div className="flex items-center gap-2">
<Button disabled={pending} onClick={() => setOpen(false)} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button disabled={pending || !name.trim()} onClick={() => void submit()} type="button">
{p.startWork}
</Button>
</div>
</DialogFooter>
)}
</DialogContent>
</Dialog>
</>
)
}
// Collapsible header shared by the repo (emphasis) and worktree levels: a toggle
// button with a leading glyph, plus an optional trailing action (the +).
export function WorkspaceHeader({
action,
count,
emphasis = false,
icon,
label,
onToggle,
open,
title
}: {
action?: React.ReactNode
count: React.ReactNode
emphasis?: boolean
icon: React.ReactNode
label: string
onToggle: () => void
open: boolean
/** Hover tooltip — the lane's full on-disk path (worktree / repo root). */
title?: string
}) {
return (
<div
className={cn(
'group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem]',
emphasis ? 'font-semibold text-(--ui-text-secondary)' : 'font-medium text-(--ui-text-tertiary)'
)}
>
<button
className={cn(
'flex min-w-0 flex-1 items-center gap-1.5 bg-transparent text-left',
emphasis ? 'hover:text-foreground' : 'hover:text-(--ui-text-secondary)'
)}
onClick={onToggle}
type="button"
>
<SidebarRowLead>{icon}</SidebarRowLead>
<LaneLabel label={label} title={title ? `${label}\n${title}` : label} />
<span className="shrink-0">
<SidebarCount>{count}</SidebarCount>
</span>
<DisclosureCaret
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
</button>
{action}
</div>
)
}

View file

@ -77,6 +77,7 @@ interface SessionActions {
pinned?: boolean
profile?: string
onPin?: () => void
onBranch?: () => void
onArchive?: () => void
onDelete?: () => void
}
@ -92,7 +93,7 @@ interface ItemSpec {
variant?: 'destructive'
}
function useSessionActions({ sessionId, title, pinned = false, profile, onPin, onArchive, onDelete }: SessionActions) {
function useSessionActions({ sessionId, title, pinned = false, profile, onPin, onBranch, onArchive, onDelete }: SessionActions) {
const { t } = useI18n()
const r = t.sidebar.row
const [renameOpen, setRenameOpen] = useState(false)
@ -130,6 +131,15 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
void exportSession(sessionId, { profile, title })
}
},
{
disabled: !onBranch,
icon: 'git-branch',
label: r.branchFrom,
onSelect: () => {
triggerHaptic('selection')
onBranch?.()
}
},
{
disabled: !sessionId,
icon: 'edit',
@ -175,6 +185,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
appearance={Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
disabled={!sessionId}
errorMessage={r.copyIdFailed}
iconClassName="size-3.5 text-current"
key={r.copyId}
label={r.copyId}
onCopyError={err => notifyError(err, r.copyIdFailed)}

View file

@ -15,14 +15,18 @@ import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
session: SessionInfo
/** TUI-style tree stem for branched sessions (`└─ ` / `├─ `). */
branchStem?: string
isPinned: boolean
isSelected: boolean
isWorking: boolean
onArchive: () => void
onBranch?: () => void
onDelete: () => void
onPin: () => void
onResume: () => void
@ -51,10 +55,12 @@ function formatAge(seconds: number, r: Translations['sidebar']['row']): string {
export function SidebarSessionRow({
session,
branchStem,
isPinned,
isSelected,
isWorking,
onArchive,
onBranch,
onDelete,
onPin,
onResume,
@ -84,6 +90,7 @@ export function SidebarSessionRow({
return (
<SessionContextMenu
onArchive={onArchive}
onBranch={onBranch}
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
@ -91,9 +98,38 @@ export function SidebarSessionRow({
sessionId={session.id}
title={title}
>
<div
<SidebarRowShell
actions={
<div className="relative z-2 grid w-[1.375rem] place-items-center">
{!isWorking && (
<span className="pointer-events-none absolute right-6 top-1/2 min-w-6 -translate-y-1/2 text-right text-[0.625rem] leading-none text-(--ui-text-tertiary) opacity-0 transition-opacity group-hover:opacity-100">
{age}
</span>
)}
<SessionActionsMenu
onArchive={onArchive}
onBranch={onBranch}
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
profile={session.profile}
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"
title={r.sessionActions}
variant="ghost"
>
<Codicon name="kebab-vertical" size="0.875rem" />
</Button>
</SessionActionsMenu>
</div>
}
className={cn(
'group relative grid min-h-[1.625rem] cursor-pointer grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none',
'group relative cursor-pointer transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none',
isSelected && 'bg-(--ui-row-active-background)',
isWorking && 'text-foreground',
// Opaque surface while lifted so the dragged row erases what's under
@ -123,9 +159,7 @@ export function SidebarSessionRow({
{...rest}
>
{isWorking && !needsInput && <span aria-hidden="true" className="arc-border" />}
<button
className="z-0 flex min-w-0 items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left group-hover:pr-12"
onClick={event => {
<SidebarRowBody className={cn('z-0 group-hover:pr-12', branchStem && 'pl-3.5')} onClick={event => {
if (event.shiftKey) {
event.preventDefault()
event.stopPropagation()
@ -150,49 +184,25 @@ export function SidebarSessionRow({
onResume()
}}
type="button"
>
{reorderable ? (
<span
{...dragHandleProps}
aria-label={handleLabel}
className={cn(
// Scope the dot↔grabber swap to a local group so the grabber
// only reveals when hovering/focusing the handle itself, not
// anywhere on the row. Width MUST match the non-reorderable dot
// column (w-3.5) so rows don't shift horizontally when reorder is
// toggled (e.g. scoped → ALL-profiles view).
'group/handle relative -my-0.5 grid w-3.5 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing',
// The quest-glow box-shadow extends past the dot; let it bleed
// out instead of being clipped by this handle's overflow-hidden.
needsInput && 'overflow-visible'
)}
data-reorder-handle
onClick={event => event.stopPropagation()}
<SidebarRowGrab
ariaLabel={handleLabel}
dragging={dragging}
dragHandleProps={dragHandleProps}
leadClassName={needsInput ? 'overflow-visible' : undefined}
>
<SidebarRowDot
<SessionRowLeadDot
branchStem={branchStem}
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
isWorking={isWorking}
needsInput={needsInput}
/>
<Codicon
className={cn(
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/handle:opacity-80 group-focus-within/handle:opacity-80 hover:text-(--ui-text-secondary)',
dragging && 'text-(--ui-text-secondary) opacity-100'
)}
name="grabber"
size="0.75rem"
/>
</span>
</SidebarRowGrab>
) : (
<span
className={cn(
'grid w-3.5 shrink-0 place-items-center',
needsInput ? 'overflow-visible' : 'overflow-hidden'
)}
>
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
<SidebarRowLead className={needsInput ? 'overflow-visible' : 'overflow-hidden'}>
<SessionRowLeadDot branchStem={branchStem} isWorking={isWorking} needsInput={needsInput} />
</SidebarRowLead>
)}
{handoffSource && handoffLabel ? (
<Tip label={r.handoffOrigin(handoffLabel)}>
@ -203,41 +213,38 @@ export function SidebarSessionRow({
/>
</Tip>
) : null}
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
<SidebarRowLabel className="flex-1 font-normal group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
</span>
</button>
<div className="relative z-2 grid w-[1.375rem] place-items-center">
{!isWorking && (
<span className="pointer-events-none absolute right-6 top-1/2 min-w-6 -translate-y-1/2 text-right text-[0.625rem] leading-none text-(--ui-text-tertiary) opacity-0 transition-opacity group-hover:opacity-100">
{age}
</span>
)}
<SessionActionsMenu
onArchive={onArchive}
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
profile={session.profile}
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"
title={r.sessionActions}
variant="ghost"
>
<Codicon name="ellipsis" size="0.875rem" />
</Button>
</SessionActionsMenu>
</div>
</div>
</SidebarRowLabel>
</SidebarRowBody>
</SidebarRowShell>
</SessionContextMenu>
)
}
function SessionRowLeadDot({
branchStem,
isWorking,
needsInput = false,
className
}: {
branchStem?: string
isWorking: boolean
needsInput?: boolean
className?: string
}) {
return (
<span className={cn('flex items-center gap-0.5', className)}>
{branchStem ? (
<span aria-hidden className="shrink-0 font-mono text-[0.625rem] leading-none text-(--ui-text-quaternary)">
{branchStem}
</span>
) : null}
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
)
}
function SidebarRowDot({
isWorking,
needsInput = false,

View file

@ -4,30 +4,35 @@ import { useVirtualizer } from '@tanstack/react-virtual'
import { type FC, useCallback, useRef } from 'react'
import type { SessionInfo } from '@/hermes'
import { type SidebarSessionEntry } from '@/lib/session-branch-tree'
import { cn } from '@/lib/utils'
import { sessionPinId } from '@/store/session'
import { SidebarSessionRow } from './session-row'
interface SessionRowCommonProps {
branchStem?: string
isPinned: boolean
isSelected: boolean
isWorking: boolean
onArchive: () => void
onBranch?: () => void
onDelete: () => void
onPin: () => void
onResume: () => void
reorderable?: boolean
}
interface VirtualSessionListProps {
activeSessionId: null | string
className?: string
entries: SidebarSessionEntry[]
onArchiveSession: (sessionId: string) => void
onBranchSession?: (sessionId: string, profile?: string) => void
onDeleteSession: (sessionId: string) => void
onResumeSession: (sessionId: string) => void
onTogglePin: (sessionId: string) => void
pinned: boolean
sessions: SessionInfo[]
sortable: boolean
workingSessionIdSet: Set<string>
}
@ -38,21 +43,22 @@ const OVERSCAN_ROWS = 12
export const VirtualSessionList: FC<VirtualSessionListProps> = ({
activeSessionId,
className,
entries,
onArchiveSession,
onBranchSession,
onDeleteSession,
onResumeSession,
onTogglePin,
pinned,
sessions,
sortable,
workingSessionIdSet
}) => {
const scrollerRef = useRef<HTMLDivElement | null>(null)
const virtualizer = useVirtualizer({
count: sessions.length,
count: entries.length,
estimateSize: () => ROW_ESTIMATE_PX,
getItemKey: index => sessions[index]?.id ?? index,
getItemKey: index => entries[index]?.session.id ?? index,
getScrollElement: () => scrollerRef.current,
// jsdom-friendly default; the real rect takes over on first observe.
initialRect: { height: 600, width: 240 },
@ -65,23 +71,29 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
const paddingBottom = Math.max(0, totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0))
const rows = virtualItems.map(virtualItem => {
const session = sessions[virtualItem.index]
const entry = entries[virtualItem.index]
if (!session) {
if (!entry) {
return null
}
const { branchStem, session } = entry
const reorderable = sortable && !branchStem
const commonProps: SessionRowCommonProps = {
branchStem,
isPinned: pinned,
isSelected: session.id === activeSessionId,
isWorking: workingSessionIdSet.has(session.id),
onArchive: () => onArchiveSession(session.id),
onBranch: onBranchSession ? () => onBranchSession(session.id, session.profile) : undefined,
onDelete: () => onDeleteSession(session.id),
onPin: () => onTogglePin(sessionPinId(session)),
onResume: () => onResumeSession(session.id)
onResume: () => onResumeSession(session.id),
reorderable
}
return sortable ? (
return reorderable ? (
<VirtualSortableRow
index={virtualItem.index}
key={session.id}

View file

@ -1,149 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { HermesWorktreeInfo } from '@/global'
import type { SessionInfo } from '@/types/hermes'
import { uniqueCwds, workspaceGroupsFor, workspaceTreeFor, type WorktreeResolver } from './workspace-groups'
let nextId = 0
function makeSession(cwd: null | string, overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
archived: false,
cwd,
ended_at: null,
id: `s${nextId++}`,
input_tokens: 0,
is_active: false,
last_active: 1_000,
message_count: 1,
model: 'claude',
output_tokens: 0,
preview: null,
source: 'cli',
started_at: 1_000,
title: null,
tool_call_count: 0,
...overrides
}
}
const labels = (sessions: SessionInfo[]) => workspaceGroupsFor(sessions, 'No workspace').map(g => g.label)
describe('workspaceGroupsFor', () => {
it('groups by full cwd, not by basename — same-named folders are separate groups', () => {
const groups = workspaceGroupsFor(
[makeSession('/a/hermes-agent/apps/desktop'), makeSession('/a/hermes-agent-wt-rtl/apps/desktop')],
'No workspace'
)
expect(groups).toHaveLength(2)
})
it('disambiguates colliding basenames by walking up the path', () => {
expect(
labels([makeSession('/a/hermes-agent/apps/desktop'), makeSession('/a/hermes-agent-wt-rtl/apps/desktop')])
).toEqual(['hermes-agent/apps/desktop', 'hermes-agent-wt-rtl/apps/desktop'])
})
it('leaves a unique basename as its short label', () => {
expect(labels([makeSession('/a/hermes-agent/apps/desktop'), makeSession('/b/heval-py')])).toEqual([
'desktop',
'heval-py'
])
})
it('grows the prefix past one segment when the parent also collides', () => {
expect(labels([makeSession('/x/proj/apps/desktop'), makeSession('/y/proj/apps/desktop')])).toEqual([
'x/proj/apps/desktop',
'y/proj/apps/desktop'
])
})
it('keeps the synthetic no-workspace group untouched even if a real group shares its label', () => {
const groups = workspaceGroupsFor([makeSession(null), makeSession('/a/No workspace')], 'No workspace')
const noWorkspace = groups.find(g => g.path === null)
expect(noWorkspace?.label).toBe('No workspace')
})
})
const info = (over: Partial<HermesWorktreeInfo> & Pick<HermesWorktreeInfo, 'repoRoot' | 'worktreeRoot'>): HermesWorktreeInfo => ({
branch: null,
isMainWorktree: false,
...over
})
describe('workspaceTreeFor', () => {
it('heuristic nests `<repo>-wt-<branch>` under its sibling repo', () => {
const tree = workspaceTreeFor(
[makeSession('/www/hermes-agent'), makeSession('/www/hermes-agent-wt-rtl')],
'No workspace'
)
expect(tree).toHaveLength(1)
expect(tree[0].label).toBe('hermes-agent')
expect(tree[0].groups.map(g => g.label).sort()).toEqual(['hermes-agent', 'rtl'])
})
it('git metadata is authoritative — worktrees group by repoRoot regardless of directory naming', () => {
const resolver: WorktreeResolver = cwd => {
if (cwd === '/www/hermes-agent') {
return info({ repoRoot: '/www/hermes-agent', worktreeRoot: '/www/hermes-agent', isMainWorktree: true, branch: 'main' })
}
if (cwd === '/elsewhere/ha-rtl') {
return info({ repoRoot: '/www/hermes-agent', worktreeRoot: '/elsewhere/ha-rtl', branch: 'rtl' })
}
return null
}
const tree = workspaceTreeFor(
[makeSession('/www/hermes-agent'), makeSession('/elsewhere/ha-rtl')],
'No workspace',
resolver
)
expect(tree).toHaveLength(1)
expect(tree[0].label).toBe('hermes-agent')
// The main checkout labels by directory (its branch is transient — using it
// would misattribute old sessions to the currently checked-out branch);
// linked worktrees label by branch.
expect(tree[0].groups.map(g => g.label)).toEqual(['hermes-agent', 'rtl'])
})
it('a standalone directory is its own parent (always parent → worktree → sessions)', () => {
const tree = workspaceTreeFor([makeSession('/www/heval-node')], 'No workspace')
expect(tree).toHaveLength(1)
expect(tree[0].label).toBe('heval-node')
expect(tree[0].groups).toHaveLength(1)
expect(tree[0].groups[0].label).toBe('heval-node')
})
it('aggregates session counts across a repos worktrees', () => {
const tree = workspaceTreeFor(
[makeSession('/www/ha'), makeSession('/www/ha-wt-x'), makeSession('/www/ha-wt-x')],
'No workspace'
)
const parent = tree.find(p => p.label === 'ha')
expect(parent?.sessionCount).toBe(3)
})
it('no-workspace sessions form their own parent', () => {
const tree = workspaceTreeFor([makeSession(null)], 'No workspace')
expect(tree).toHaveLength(1)
expect(tree[0].label).toBe('No workspace')
expect(tree[0].path).toBeNull()
})
})
describe('uniqueCwds', () => {
it('dedupes and drops empty/whitespace cwds', () => {
expect(uniqueCwds([makeSession('/a'), makeSession('/a'), makeSession(null), makeSession(' ')])).toEqual(['/a'])
})
})

View file

@ -1,326 +0,0 @@
import type { HermesWorktreeInfo } from '@/global'
import type { SessionInfo } from '@/hermes'
export interface SidebarSessionGroup {
id: string
label: string
path: null | string
sessions: SessionInfo[]
// Profile color for the ALL-profiles view; absent for workspace groups.
color?: null | string
loadingMore?: boolean
mode?: 'profile' | 'source' | 'workspace'
onLoadMore?: () => void
sourceId?: string
totalCount?: number
}
const NO_WORKSPACE_ID = '__no_workspace__'
/** Path split into segments, ignoring trailing slashes and mixed separators. */
const segments = (path: string): string[] => path.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean)
/** Last path segment. */
export const baseName = (path: string): string | undefined => segments(path).pop()
/** The segments above the basename. */
const parentSegments = (path: string): string[] => segments(path).slice(0, -1)
interface Labelable {
id: string
label: string
path: null | string
}
/**
* Disambiguate groups whose basename collides (worktrees all end in the same
* `apps/desktop`, sibling repos share a folder name, etc.) by walking up the
* path and prepending parent segments until each colliding label is unique
* e.g. `hermes-agent/desktop` vs `hermes-agent-wt-rtl/desktop`. Groups with a
* unique basename keep their short label untouched.
*/
function disambiguateLabels(groups: Labelable[]): void {
const byLabel = new Map<string, Labelable[]>()
for (const group of groups) {
const bucket = byLabel.get(group.label)
if (bucket) {
bucket.push(group)
} else {
byLabel.set(group.label, [group])
}
}
for (const bucket of byLabel.values()) {
if (bucket.length < 2) {
continue
}
// Only groups backed by a real path can grow a prefix; the synthetic
// "No workspace" group has no path and stays as-is.
const pathed = bucket.filter(group => group.path)
if (pathed.length < 2) {
continue
}
const parents = new Map(pathed.map(group => [group.id, parentSegments(group.path!)]))
let depth = 1
// Grow the prefix one parent segment at a time until every label in the
// bucket is distinct, or we run out of parent segments to add.
while (depth <= Math.max(...pathed.map(g => parents.get(g.id)!.length))) {
const labels = new Map<string, number>()
for (const group of pathed) {
const segs = parents.get(group.id)!
const prefix = segs.slice(-depth).join('/')
const base = baseName(group.path!) ?? group.path!
group.label = prefix ? `${prefix}/${base}` : base
labels.set(group.label, (labels.get(group.label) ?? 0) + 1)
}
if ([...labels.values()].every(count => count === 1)) {
break
}
depth += 1
}
}
}
export function workspaceGroupsFor(
sessions: SessionInfo[],
noWorkspaceLabel: string,
options: { preserveSessionOrder?: boolean } = {}
): SidebarSessionGroup[] {
const groups = new Map<string, SidebarSessionGroup>()
for (const session of sessions) {
const path = session.cwd?.trim() || ''
const id = path || NO_WORKSPACE_ID
const label = baseName(path) || path || noWorkspaceLabel
const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] }
group.sessions.push(session)
groups.set(id, group)
}
if (!options.preserveSessionOrder) {
// Groups keep recency order (Map insertion = first-seen in the recency-sorted
// input, so an active project floats up), but rows *within* a group sort by
// creation time so they don't reshuffle every time a message lands — keeps
// muscle memory intact.
for (const group of groups.values()) {
group.sessions.sort((a, b) => b.started_at - a.started_at)
}
}
const result = [...groups.values()]
disambiguateLabels(result)
return result
}
/**
* A worktree's main repo and all its linked worktrees collapse into ONE parent
* (keyed by the repo root); each worktree is a child group; sessions hang off
* the worktree they ran in. `parent → worktree → sessions`.
*/
export interface SidebarWorkspaceTree {
id: string
label: string
path: null | string
groups: SidebarSessionGroup[]
sessionCount: number
}
/** Resolves a session cwd to git-worktree identity (from the local fs probe). */
export type WorktreeResolver = (cwd: string) => HermesWorktreeInfo | null | undefined
interface WorkspacePlacement {
parentKey: string
parentLabel: string
parentPath: string
worktreeKey: string
worktreeLabel: string
worktreePath: string
}
/** Replace a path's final segment, preserving its prefix + separators. */
const withBaseName = (path: string, name: string): string =>
path.replace(/[/\\]+$/, '').replace(/[^/\\]+$/, name)
/**
* Path-only fallback for when git metadata is unavailable (remote backends,
* unreadable paths). Mirrors the git layout: a `<repo>-wt-<branch>` directory
* nests under its sibling `<repo>`; any other directory is its own repo root.
*/
function placeByHeuristic(path: string): WorkspacePlacement | null {
const base = baseName(path)
if (!base) {
return null
}
const worktreeMatch = base.match(/^(.+)-wt-(.+)$/)
if (worktreeMatch) {
const repo = worktreeMatch[1]
const repoPath = withBaseName(path, repo)
return {
parentKey: repoPath,
parentLabel: repo,
parentPath: repoPath,
worktreeKey: path,
worktreeLabel: worktreeMatch[2],
worktreePath: path
}
}
return {
parentKey: path,
parentLabel: base,
parentPath: path,
worktreeKey: path,
worktreeLabel: base,
worktreePath: path
}
}
function placeWorkspace(path: string, resolver?: WorktreeResolver): WorkspacePlacement | null {
const info = resolver?.(path)
if (info?.repoRoot && info.worktreeRoot) {
const dirLabel = baseName(info.worktreeRoot) || info.worktreeRoot
return {
parentKey: info.repoRoot,
parentLabel: baseName(info.repoRoot) ?? info.repoRoot,
parentPath: info.repoRoot,
worktreeKey: info.worktreeRoot,
// The main checkout's branch is transient — it changes as you work, so a
// branch label would misattribute every past session to whatever branch
// is checked out *now*. Label it by directory. Linked worktrees are
// per-branch by construction, so branch is the clearest label there.
worktreeLabel: info.isMainWorktree ? dirLabel : info.branch || dirLabel,
worktreePath: info.worktreeRoot
}
}
return placeByHeuristic(path)
}
/** Unique, non-empty session cwds — the batch to probe for worktree info. */
export function uniqueCwds(sessions: SessionInfo[]): string[] {
const seen = new Set<string>()
for (const session of sessions) {
const path = session.cwd?.trim()
if (path) {
seen.add(path)
}
}
return [...seen]
}
/**
* Build the `parent → worktree → sessions` tree. Parents keep recency order
* (first-seen in the recency-sorted input); worktree groups within a parent do
* too, while rows inside a worktree sort by creation time (stable muscle memory,
* matching `workspaceGroupsFor`).
*/
export function workspaceTreeFor(
sessions: SessionInfo[],
noWorkspaceLabel: string,
resolver?: WorktreeResolver,
options: { preserveSessionOrder?: boolean } = {}
): SidebarWorkspaceTree[] {
interface WorktreeEntry {
group: SidebarSessionGroup
parentKey: string
parentLabel: string
parentPath: string
}
const worktrees = new Map<string, WorktreeEntry>()
const noWorkspace: SessionInfo[] = []
for (const session of sessions) {
const path = session.cwd?.trim() || ''
if (!path) {
noWorkspace.push(session)
continue
}
const placement = placeWorkspace(path, resolver)
if (!placement) {
noWorkspace.push(session)
continue
}
let entry = worktrees.get(placement.worktreeKey)
if (!entry) {
entry = {
group: { id: placement.worktreeKey, label: placement.worktreeLabel, path: placement.worktreePath, sessions: [] },
parentKey: placement.parentKey,
parentLabel: placement.parentLabel,
parentPath: placement.parentPath
}
worktrees.set(placement.worktreeKey, entry)
}
entry.group.sessions.push(session)
}
if (!options.preserveSessionOrder) {
for (const entry of worktrees.values()) {
entry.group.sessions.sort((a, b) => b.started_at - a.started_at)
}
}
const parents = new Map<string, SidebarWorkspaceTree>()
for (const entry of worktrees.values()) {
let parent = parents.get(entry.parentKey)
if (!parent) {
parent = { id: entry.parentKey, label: entry.parentLabel, path: entry.parentPath, groups: [], sessionCount: 0 }
parents.set(entry.parentKey, parent)
}
parent.groups.push(entry.group)
parent.sessionCount += entry.group.sessions.length
}
const result = [...parents.values()]
if (noWorkspace.length) {
result.push({
id: NO_WORKSPACE_ID,
label: noWorkspaceLabel,
path: null,
groups: [{ id: NO_WORKSPACE_ID, label: noWorkspaceLabel, path: null, sessions: noWorkspace }],
sessionCount: noWorkspace.length
})
}
// Parents that collide on basename grow a path prefix; worktree labels that
// collide inside a parent do the same.
disambiguateLabels(result)
for (const parent of result) {
disambiguateLabels(parent.groups)
}
return result
}

View file

@ -82,7 +82,9 @@ describe('mergeSessionPage', () => {
const previous = [session({ id: 'a' }), session({ id: 'b' })]
const incoming = [session({ id: 'a' })]
expect(mergeSessionPage(previous, incoming, [])).toBe(incoming)
// Content, not identity: the title-carry map rebuilds the array even when
// nothing is carried, and `incoming` is a fresh server page every fetch.
expect(mergeSessionPage(previous, incoming, [])).toEqual(incoming)
})
it('keeps a still-working session the server omitted', () => {
@ -201,16 +203,14 @@ describe('workspaceCwdForNewSession', () => {
expect(workspaceCwdForNewSession()).toBe('/home/user/configured')
})
it('falls back to the remembered workspace when no configured default is set', () => {
it('starts detached (no inherited cwd) when no default project dir is configured', () => {
// A bare new chat must NOT inherit the sticky/remembered or live workspace —
// that's the "why is my new session already on a branch" bug. Only an
// explicit configured default pre-attaches.
window.localStorage.setItem('hermes.desktop.workspace-cwd', '/home/user/sticky')
expect(workspaceCwdForNewSession()).toBe('/home/user/sticky')
})
it('falls back to the live cwd when neither configured nor remembered values exist', () => {
$currentCwd.set('/home/user/live')
expect(workspaceCwdForNewSession()).toBe('/home/user/live')
expect(workspaceCwdForNewSession()).toBe('')
})
it('does not rewrite the live cwd while a session is active', () => {
@ -238,8 +238,10 @@ describe('workspaceCwdForNewSession', () => {
setCurrentCwd('/backend/project-b')
expect(workspaceCwdForNewSession()).toBe('/backend/project-b')
// Back on local with no configured default: a bare new chat is detached and
// never reads the remote keys (nor inherits the sticky local workspace).
$connection.set(null)
expect(workspaceCwdForNewSession()).toBe('/local/project')
expect(workspaceCwdForNewSession()).toBe('')
})
})

View file

@ -21,6 +21,13 @@ const COMPOSER_PROVIDER_KEY = 'hermes.desktop.composer.provider'
const COMPOSER_EFFORT_KEY = 'hermes.desktop.composer.reasoning-effort'
const COMPOSER_FAST_KEY = 'hermes.desktop.composer.fast'
// The last chat the user had open, so a relaunch lands back on it instead of an
// empty new-chat. Stored (not runtime) id — the route is keyed by stored id.
const LAST_SESSION_KEY = 'hermes.desktop.lastSessionId'
export const getRememberedSessionId = (): null | string => storedString(LAST_SESSION_KEY)
export const setRememberedSessionId = (id: null | string) => persistString(LAST_SESSION_KEY, id)
let configuredDefaultProjectDir = ''
function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string {
@ -30,6 +37,7 @@ function workspaceCwdKey(connection: HermesConnection | null = $connection.get()
const base = encodeURIComponent(connection.baseUrl || 'remote')
const profile = encodeURIComponent(connection.profile || 'default')
return `${WORKSPACE_CWD_KEY}.remote.${base}.${profile}`
}
@ -75,6 +83,7 @@ export async function ensureDefaultWorkspaceCwd(): Promise<void> {
if ($connection.get()?.mode === 'remote') {
seedLiveCwd(remembered)
return
}
@ -146,17 +155,32 @@ export function mergeSessionPage(
): SessionInfo[] {
const keep = keepIds instanceof Set ? keepIds : new Set(keepIds)
// Carry a known title onto a row that arrives title-less, so a freshly
// submitted session (e.g. a branch draft) holds its placeholder instead of
// flashing its raw message preview in the gap between persist and the async
// auto-titler. A real clear sets the local title null first, so this never
// masks one.
const prevById = new Map(previous.map(session => [session.id, session]))
const merged = incoming.map(session => {
if (session.title?.trim()) {
return session
}
const carried = prevById.get(session.id)?.title?.trim()
return carried ? { ...session, title: carried } : session
})
if (keep.size === 0) {
return incoming
return merged
}
const incomingIds = new Set(incoming.map(session => session.id))
const incomingIds = new Set(merged.map(session => session.id))
// Deduplicate by compression lineage: when auto-compression rotates the tip
// id (old #4 → new #5), the incoming page carries the new tip but the
// previous list still holds the old one. Without lineage-level dedup both
// rows survive as separate sidebar entries (fixes #43483).
const incomingLineageKeys = new Set(
incoming.map(session => session._lineage_root_id ?? session.id)
merged.map(session => session._lineage_root_id ?? session.id)
)
const survivors = previous.filter(
@ -166,7 +190,7 @@ export function mergeSessionPage(
(keep.has(session.id) || (session._lineage_root_id != null && keep.has(session._lineage_root_id)))
)
return survivors.length ? [...survivors, ...incoming] : incoming
return survivors.length ? [...survivors, ...merged] : merged
}
export const $connection = atom<HermesConnection | null>(null)
@ -318,7 +342,13 @@ export const workspaceCwdForNewSession = (): string => {
return getRememberedWorkspaceCwd()
}
return getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim()
// A bare new chat starts DETACHED — no inherited cwd, so the composer's coding
// rail (which keys off $currentCwd) shows no branch and the first message runs
// in the gateway's default rather than silently in the last repo you touched.
// Only an explicit default-project-dir setting pre-attaches. Entering a
// project/worktree attaches its cwd directly (startSessionInWorkspace), so the
// "remember where I was when I'm in a project" case is unaffected.
return getConfiguredDefaultProjectDir()
}
export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next)