mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
feat(desktop): render backend-authoritative projects sidebar
This commit is contained in:
parent
74352a1e61
commit
488ae376db
23 changed files with 3854 additions and 1192 deletions
158
apps/desktop/src/app/chat/sidebar/chrome.tsx
Normal file
158
apps/desktop/src/app/chat/sidebar/chrome.tsx
Normal 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 1–2px 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
289
apps/desktop/src/app/chat/sidebar/project-dialog.tsx
Normal file
289
apps/desktop/src/app/chat/sidebar/project-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
265
apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx
Normal file
265
apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
apps/desktop/src/app/chat/sidebar/projects/index.ts
Normal file
15
apps/desktop/src/app/chat/sidebar/projects/index.ts
Normal 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'
|
||||
128
apps/desktop/src/app/chat/sidebar/projects/model.ts
Normal file
128
apps/desktop/src/app/chat/sidebar/projects/model.ts
Normal 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)]
|
||||
}
|
||||
155
apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx
Normal file
155
apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
206
apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx
Normal file
206
apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
144
apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx
Normal file
144
apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
582
apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts
Normal file
582
apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts
Normal 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
|
||||
}
|
||||
362
apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx
Normal file
362
apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 repo’s 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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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('')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue