feat(desktop): worktree-aware sidebar grouping + composer/sidebar UX fixes

Group recents as parent-repo → worktree → sessions using local git
metadata (probed over IPC, with a path-name heuristic fallback for
remote backends). Single-worktree repos collapse to one level. Sessions
order by creation time and never reshuffle on new messages.

Also: fuse the status stack to the composer border, restore icon actions
in the queue panel, fix sidebar label truncation and drag styling, hide
sticky-message attachments while pinned, and bump the terminal font.
This commit is contained in:
Brooklyn Nicholson 2026-06-12 18:18:39 -05:00
parent a118b94a85
commit e90672696e
21 changed files with 1298 additions and 140 deletions

View file

@ -0,0 +1,174 @@
'use strict'
// Resolve git-worktree relationships for a set of session cwds, reading git's
// on-disk metadata directly (no `git` spawn per path):
//
// - A normal checkout has a `.git` DIRECTORY at its root → it's the main
// worktree; its repo root IS that directory's parent.
// - A linked worktree has a `.git` FILE: `gitdir: <repo>/.git/worktrees/<name>`.
// That admin dir's `commondir` points back at the shared `<repo>/.git`, whose
// parent is the main repo root.
//
// Grouping by repoRoot therefore clusters a repo's main checkout with all of its
// linked worktrees, regardless of how the worktree directories are named. The
// branch (read from the worktree's own HEAD) gives each worktree a meaningful
// label.
const fs = require('node:fs')
const path = require('node:path')
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
// Walk up from `start` to the nearest ancestor that carries a `.git` entry
// (file for a linked worktree, dir for the main checkout). Capped so a stray
// path can't loop forever.
function findGitHost(start, fsImpl) {
let dir = start
for (let i = 0; i < 64; i += 1) {
const dotgit = path.join(dir, '.git')
try {
if (fsImpl.existsSync(dotgit)) {
return dir
}
} catch {
return null
}
const parent = path.dirname(dir)
if (parent === dir) {
return null
}
dir = parent
}
return null
}
function readBranch(gitDir, fsImpl) {
try {
const head = fsImpl.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim()
const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/)
if (ref) {
return ref[1]
}
// Detached HEAD: surface a short sha so the worktree still gets a label.
return /^[0-9a-f]{7,40}$/i.test(head) ? head.slice(0, 8) : null
} catch {
return null
}
}
// Given the directory that owns the `.git` entry, resolve its worktree identity.
function resolveFromHost(host, fsImpl) {
const dotgit = path.join(host, '.git')
let stat
try {
stat = fsImpl.statSync(dotgit)
} catch {
return null
}
if (stat.isDirectory()) {
return {
repoRoot: host,
worktreeRoot: host,
isMainWorktree: true,
branch: readBranch(dotgit, fsImpl)
}
}
// Linked worktree: `.git` is a file pointing at the admin dir.
let contents
try {
contents = fsImpl.readFileSync(dotgit, 'utf8').trim()
} catch {
return null
}
const match = contents.match(/^gitdir:\s*(.+)$/m)
if (!match) {
return null
}
const adminDir = path.resolve(host, match[1].trim())
// `commondir` resolves to the shared `<repo>/.git`; fall back to walking two
// levels up from `<repo>/.git/worktrees/<name>` if it's missing.
let commonDir
try {
const rel = fsImpl.readFileSync(path.join(adminDir, 'commondir'), 'utf8').trim()
commonDir = path.resolve(adminDir, rel)
} catch {
commonDir = path.dirname(path.dirname(adminDir))
}
return {
repoRoot: path.dirname(commonDir),
worktreeRoot: host,
isMainWorktree: false,
branch: readBranch(adminDir, fsImpl)
}
}
function resolveWorktree(startPath, fsImpl = fs) {
let resolved
try {
resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Worktree lookup' })
} catch {
return null
}
let start = resolved
try {
const stat = fsImpl.statSync(resolved)
if (!stat.isDirectory()) {
start = path.dirname(resolved)
}
} catch {
return null
}
const host = findGitHost(start, fsImpl)
if (!host) {
return null
}
return resolveFromHost(host, fsImpl)
}
// Batch entry point for the renderer: maps each requested cwd to its worktree
// info (or null when it isn't inside a git checkout / can't be read). Dedupes so
// many sessions sharing a cwd cost one lookup.
async function worktreesForIpc(cwds, options = {}) {
const fsImpl = options.fs || fs
const list = Array.isArray(cwds) ? cwds : []
const out = {}
for (const cwd of list) {
if (typeof cwd !== 'string' || !cwd.trim() || cwd in out) {
continue
}
out[cwd] = resolveWorktree(cwd, fsImpl)
}
return out
}
module.exports = {
resolveWorktree,
worktreesForIpc
}

View file

@ -41,6 +41,7 @@ const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-ma
const { buildDesktopBackendEnv } = require('./backend-env.cjs')
const { readDirForIpc } = require('./fs-read-dir.cjs')
const { gitRootForIpc } = require('./git-root.cjs')
const { worktreesForIpc } = require('./git-worktrees.cjs')
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
const {
buildPosixCleanupScript,
@ -5954,6 +5955,8 @@ ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dir
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
ipcMain.handle('hermes:fs:worktrees', async (_event, cwds) => worktreesForIpc(cwds))
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
if (!nodePty) {
throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.')

View file

@ -54,6 +54,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
worktrees: cwds => ipcRenderer.invoke('hermes:fs:worktrees', cwds),
terminal: {
dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id),
resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size),

View file

@ -1741,7 +1741,6 @@ export function ChatBar({
'group/composer-surface relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] transition-[border-color] duration-200 ease-out focus-within:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
COMPOSER_DROP_FADE_CLASS,
'group-has-data-[state=open]/composer:border-t-transparent',
'group-data-[status-stack]/composer:border-t-transparent',
dragActive && COMPOSER_DROP_ACTIVE_CLASS
)}
data-slot="composer-surface"

View file

@ -1,7 +1,9 @@
import { StatusRow } from '@/components/chat/status-row'
import { StatusSection } from '@/components/chat/status-section'
import { Button } from '@/components/ui/button'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { ArrowUp, Pencil, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { QueuedPromptEntry } from '@/store/composer-queue'
@ -38,32 +40,46 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
isEditing && 'border-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
)}
key={entry.id}
leading={
<span aria-hidden className="size-3.5 shrink-0 rounded-full border border-foreground/35 bg-transparent" />
}
trailing={
<>
<Button
disabled={Boolean(editingId) && !isEditing}
onClick={() => onEdit(entry)}
size="micro"
type="button"
variant="text"
>
{c.queueEdit}
</Button>
<Button
disabled={isEditing}
onClick={() => onSendNow(entry.id)}
size="micro"
type="button"
variant="secondary"
>
{busy ? c.queueSendNext : c.queueSend}
</Button>
<Button onClick={() => onDelete(entry.id)} size="micro" type="button" variant="text">
{c.queueDelete}
</Button>
<Tip label={c.queueEdit}>
<Button
aria-label={c.queueEdit}
className="size-5 rounded-md"
disabled={Boolean(editingId) && !isEditing}
onClick={() => onEdit(entry)}
size="icon-xs"
type="button"
variant="ghost"
>
<Pencil size={11} />
</Button>
</Tip>
<Tip label={busy ? c.queueSendNext : c.queueSend}>
<Button
aria-label={busy ? c.queueSendNext : c.queueSend}
className="size-5 rounded-md"
disabled={isEditing}
onClick={() => onSendNow(entry.id)}
size="icon-xs"
type="button"
variant="ghost"
>
<ArrowUp size={11} />
</Button>
</Tip>
<Tip label={c.queueDelete}>
<Button
aria-label={c.queueDelete}
className="size-5 rounded-md"
onClick={() => onDelete(entry.id)}
size="icon-xs"
type="button"
variant="ghost"
>
<Trash2 size={11} />
</Button>
</Tip>
</>
}
trailingVisible={isEditing}

View file

@ -170,14 +170,22 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
return (
<div
className="absolute inset-x-0 bottom-full z-6 -mb-[9px] max-h-[40vh] overflow-y-auto"
// Sits above the composer (bottom-full), nudged down by the shell's 0.5rem
// top pad (pt-2 on composer-root) plus 1px so its bottom edge overlaps the
// composer surface's top border. z BELOW the surface (z-4) so the surface's
// top border paints over our transparent bottom border — one seam, no
// double line.
className="absolute inset-x-0 bottom-full z-3 max-h-[40vh] translate-y-[calc(0.5rem+1px)] overflow-y-auto"
onPointerDownCapture={() => blurComposerInput()}
ref={stackRef}
>
{/* The card paints the shared --composer-fill (rest / scrolled / focused
all match the composer surface by construction); on scroll we only
ghost the CONTENT element opacity on the card would kill the blur. */}
<div className={cn(composerDockCard('top'), 'mx-1 pt-0.5 pb-1')}>
ghost the CONTENT element opacity on the card would kill the blur.
Rounded top, square bottom; the bottom border is TRANSPARENT the
composer surface's visible top border (which sits at a higher z) is the
single shared seam, so the two read as one fused capsule. */}
<div className={cn(composerDockCard('top'), 'mx-2 rounded-b-none border-b border-b-transparent pt-0.5 pb-1')}>
<div
className={cn(
'transition-opacity duration-200 ease-out',

View file

@ -125,7 +125,13 @@ function ChatHeader({
return (
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div className={titlebarHeaderTitleClass}>
<div
className={titlebarHeaderTitleClass}
style={{
maxWidth:
'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)'
}}
>
<SessionActionsMenu
align="start"
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
@ -136,7 +142,7 @@ function ChatHeader({
title={title}
>
<Button
className="pointer-events-auto flex h-6 w-full min-w-0 max-w-full gap-1 overflow-hidden border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
className="pointer-events-auto flex h-6 min-w-0 max-w-full gap-1 overflow-hidden border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
type="button"
variant="ghost"
>

View file

@ -14,7 +14,6 @@ import {
useSortable,
verticalListSortingStrategy
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -37,9 +36,10 @@ import {
import { Skeleton } from '@/components/ui/skeleton'
import { Tip } from '@/components/ui/tooltip'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { useWorktreeInfo } from '@/hooks/use-worktree-info'
import { useI18n } from '@/i18n'
import { profileColor } from '@/lib/profile-color'
import { comboTokens } from '@/lib/keybinds/combo'
import { profileColor } from '@/lib/profile-color'
import { sessionMatchesSearch } from '@/lib/session-search'
import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source'
import { cn } from '@/lib/utils'
@ -56,6 +56,7 @@ import {
$sidebarRecentsOpen,
$sidebarSessionOrderIds,
$sidebarWorkspaceOrderIds,
$sidebarWorkspaceParentOrderIds,
pinSession,
reorderPinnedSession,
SESSION_SEARCH_FOCUS_EVENT,
@ -65,6 +66,7 @@ import {
setSidebarRecentsOpen,
setSidebarSessionOrderIds,
setSidebarWorkspaceOrderIds,
setSidebarWorkspaceParentOrderIds,
SIDEBAR_SESSIONS_PAGE_SIZE,
toggleSidebarMessagingOpen,
unpinSession
@ -100,6 +102,7 @@ import { SidebarLoadMoreRow } from './load-more-row'
import { ProfileRail } from './profile-switcher'
import { SidebarSessionRow } from './session-row'
import { VirtualSessionList } from './virtual-session-list'
import { type SidebarSessionGroup, type SidebarWorkspaceTree, workspaceTreeFor } from './workspace-groups'
const VIRTUALIZE_THRESHOLD = 25
@ -152,6 +155,41 @@ const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}`
const parseGroupDndId = (id: string) =>
id.startsWith(GROUP_DND_ID_PREFIX) ? id.slice(GROUP_DND_ID_PREFIX.length) : null
// Worktree-tree parents (repo roots) reorder in their own dnd lane, distinct
// from the worktree groups (group:) and session rows nested inside them.
const PARENT_DND_ID_PREFIX = 'parent:'
const parentDndId = (id: string) => `${PARENT_DND_ID_PREFIX}${id}`
const parseParentDndId = (id: string) =>
id.startsWith(PARENT_DND_ID_PREFIX) ? id.slice(PARENT_DND_ID_PREFIX.length) : null
// Sidebar reordering is a strictly vertical list. The dragged item's transform
// is rendered Y-only in useSortableBindings (no x, no scale); this just stops
// dnd-kit's auto-scroll from dragging the rail — or the window — sideways when
// the pointer nears an edge, killing the horizontal "drag to valhalla".
const reorderAutoScroll = { threshold: { x: 0, y: 0.2 } }
function ReorderContext({
children,
onReorder,
sensors
}: {
children: React.ReactNode
onReorder?: (event: DragEndEvent) => void
sensors?: ReturnType<typeof useSensors>
}) {
return (
<DndContext
autoScroll={reorderAutoScroll}
collisionDetection={closestCenter}
onDragEnd={onReorder}
sensors={sensors}
>
{children}
</DndContext>
)
}
const countLabel = (loaded: number, total: number) => (total > loaded ? `${loaded}/${total}` : String(loaded))
const sessionTime = (s: SessionInfo) => s.last_active || s.started_at || 0
@ -208,13 +246,6 @@ function sameIds(left: string[], right: string[]) {
return left.length === right.length && left.every((item, index) => item === right[index])
}
const baseName = (path: string) =>
path
.replace(/[/\\]+$/, '')
.split(/[/\\]/)
.filter(Boolean)
.pop()
// FTS results cover sessions that aren't in the loaded page; synthesize a
// minimal SessionInfo so they render in the same row component (resume works
// by id; the snippet stands in for the preview).
@ -241,36 +272,6 @@ function searchResultToSession(result: SessionSearchResult): SessionInfo {
}
}
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__'
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)
}
}
return [...groups.values()]
}
function useSortableBindings(id: string) {
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
@ -280,7 +281,10 @@ function useSortableBindings(id: string) {
ref: setNodeRef,
reorderable: true as const,
style: {
transform: CSS.Transform.toString(transform),
// Uniform vertical list: only ever translate on Y. Ignoring x and the
// scaleX/scaleY that CSS.Transform.toString would emit keeps a dragged
// group/row from drifting sideways or morphing its size mid-drag.
transform: transform ? `translate3d(0px, ${transform.y}px, 0)` : undefined,
transition: isDragging ? undefined : transition,
willChange: isDragging ? 'transform' : undefined
}
@ -348,6 +352,7 @@ export function ChatSidebar({
const showAllProfiles = multiProfile && profileScope === ALL_PROFILES
const agentOrderIds = useStore($sidebarSessionOrderIds)
const workspaceOrderIds = useStore($sidebarWorkspaceOrderIds)
const workspaceParentOrderIds = useStore($sidebarWorkspaceParentOrderIds)
const [searchQuery, setSearchQuery] = useState('')
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
@ -403,8 +408,11 @@ export function ChatSidebar({
[sessions, showAllProfiles, profileScope]
)
// Agent session order is pinned to creation time (started_at), NOT activity —
// a new message must never float a session to the top. Position only changes
// for a brand-new session or an explicit manual drag (agentOrderIds).
const sortedSessions = useMemo(
() => [...visibleSessions].sort((a, b) => sessionTime(b) - sessionTime(a)),
() => [...visibleSessions].sort((a, b) => (b.started_at || 0) - (a.started_at || 0)),
[visibleSessions]
)
@ -524,10 +532,27 @@ export function ChatSidebar({
// Recents are local-only: messaging-platform sessions are fetched as their
// own slice ($messagingSessions) and rendered in self-managed per-platform
// sections below, so there is no source-grouping magic to untangle here.
const agentGroups = useMemo(
() => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
[agentSessions, s.noWorkspace, workspaceOrderIds]
)
//
// Workspace grouping is a `parent (repo) → worktree → sessions` tree. Git
// metadata (probed locally) is authoritative; unresolved cwds fall back to a
// path-name heuristic inside workspaceTreeFor. Parents reorder via
// workspaceParentOrderIds; worktrees within a parent via workspaceOrderIds.
const worktreeGroupingActive = agentsGrouped && !showAllProfiles
const worktreeResolver = useWorktreeInfo(agentSessions, worktreeGroupingActive)
const agentTree = useMemo<SidebarWorkspaceTree[] | undefined>(() => {
if (!worktreeGroupingActive) {
return undefined
}
const tree = workspaceTreeFor(agentSessions, s.noWorkspace, worktreeResolver)
const orderedParents = orderByIds(tree, parent => parent.id, workspaceParentOrderIds)
return orderedParents.map(parent => ({
...parent,
groups: orderByIds(parent.groups, group => group.id, workspaceOrderIds)
}))
}, [worktreeGroupingActive, agentSessions, s.noWorkspace, worktreeResolver, workspaceParentOrderIds, workspaceOrderIds])
const loadMoreForProfileGroup = useCallback(
(profile: string) => {
@ -681,28 +706,42 @@ export function ChatSidebar({
const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
const displayAgentGroups = showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
const displayAgentGroups = showAllProfiles ? profileGroups : undefined
// The recents list owns its own (virtualized) scroll container only when it's a
// long flat list. In that case it must keep its scroller even in short mode, so
// we don't flatten it (flattening would defeat virtualization). Short flat lists
// and grouped views flatten into the single outer scroll instead.
const recentsVirtualizes = !displayAgentGroups?.length && displayAgentSessions.length >= VIRTUALIZE_THRESHOLD
// and grouped views (profile groups or the worktree tree) flatten into the
// single outer scroll instead.
const recentsVirtualizes =
!displayAgentGroups?.length && !agentTree?.length && displayAgentSessions.length >= VIRTUALIZE_THRESHOLD
// Keep the persisted parent + worktree orders reconciled with what's on screen:
// freshly-seen repos/worktrees surface at the top, vanished ones drop out of
// the saved order.
useEffect(() => {
if (!displayAgentGroups?.length || showAllProfiles) {
if (!agentTree?.length) {
return
}
const next = reconcileOrderIds(
displayAgentGroups.map(g => g.id),
const nextParents = reconcileOrderIds(
agentTree.map(parent => parent.id),
workspaceParentOrderIds
)
if (!sameIds(nextParents, workspaceParentOrderIds)) {
setSidebarWorkspaceParentOrderIds(nextParents)
}
const nextWorktrees = reconcileOrderIds(
agentTree.flatMap(parent => parent.groups.map(group => group.id)),
workspaceOrderIds
)
if (!sameIds(next, workspaceOrderIds)) {
setSidebarWorkspaceOrderIds(next)
if (!sameIds(nextWorktrees, workspaceOrderIds)) {
setSidebarWorkspaceOrderIds(nextWorktrees)
}
}, [displayAgentGroups, showAllProfiles, workspaceOrderIds])
}, [agentTree, workspaceParentOrderIds, workspaceOrderIds])
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
@ -732,27 +771,57 @@ export function ChatSidebar({
const activeId = String(active.id)
const overId = String(over.id)
const activeGroup = parseGroupDndId(activeId)
const overGroup = parseGroupDndId(overId)
if (activeGroup && overGroup) {
const groups = displayAgentGroups ?? []
const oldIdx = groups.findIndex(g => g.id === activeGroup)
const newIdx = groups.findIndex(g => g.id === overGroup)
// Parent (repo) reorder.
const activeParent = parseParentDndId(activeId)
const overParent = parseParentDndId(overId)
if (activeParent || overParent) {
const parents = agentTree ?? []
const oldIdx = parents.findIndex(parent => parent.id === activeParent)
const newIdx = parents.findIndex(parent => parent.id === overParent)
if (oldIdx < 0 || newIdx < 0) {
return
}
setSidebarWorkspaceOrderIds(arrayMove(groups, oldIdx, newIdx).map(g => g.id))
setSidebarWorkspaceParentOrderIds(arrayMove(parents, oldIdx, newIdx).map(parent => parent.id))
return
}
// Worktree reorder — only within the parent that owns the dragged group. The
// persisted order is a single flat list; orderByIds applies it per parent.
const activeGroup = parseGroupDndId(activeId)
const overGroup = parseGroupDndId(overId)
if (activeGroup || overGroup) {
const parents = agentTree ?? []
const owner = parents.find(parent => parent.groups.some(group => group.id === activeGroup))
if (!owner || !owner.groups.some(group => group.id === overGroup)) {
return
}
const oldIdx = owner.groups.findIndex(group => group.id === activeGroup)
const newIdx = owner.groups.findIndex(group => group.id === overGroup)
if (oldIdx < 0 || newIdx < 0) {
return
}
const reordered = arrayMove(owner.groups, oldIdx, newIdx).map(group => group.id)
const nextFlat = parents.flatMap(parent =>
parent.id === owner.id ? reordered : parent.groups.map(group => group.id)
)
setSidebarWorkspaceOrderIds(nextFlat)
return
}
// Session reorder (only the ungrouped flat recents list).
const oldIdx = agentSessions.findIndex(s => s.id === activeId)
const newIdx = agentSessions.findIndex(s => s.id === overId)
@ -981,6 +1050,7 @@ export function ChatSidebar({
)}
sessions={displayAgentSessions}
sortable={!showAllProfiles && agentSessions.length > 1}
tree={agentTree}
workingSessionIdSet={workingSessionIdSet}
/>
)}
@ -1123,20 +1193,6 @@ function SidebarPinnedEmptyState() {
)
}
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
}
interface MessagingSection {
sourceId: string
label: string
@ -1165,6 +1221,7 @@ interface SidebarSessionsSectionProps {
headerAction?: React.ReactNode
footer?: React.ReactNode
groups?: SidebarSessionGroup[]
tree?: SidebarWorkspaceTree[]
labelMeta?: React.ReactNode
labelIcon?: React.ReactNode
sortable?: boolean
@ -1192,14 +1249,16 @@ function SidebarSessionsSection({
headerAction,
footer,
groups,
tree,
labelMeta,
labelIcon,
sortable = false,
onReorder,
dndSensors
}: SidebarSessionsSectionProps) {
const hasTreeSessions = Boolean(tree?.some(parent => parent.sessionCount > 0))
const hasGroupedSessions = Boolean(groups?.some(group => group.sessions.length > 0))
const showEmptyState = forceEmptyState || (!hasGroupedSessions && sessions.length === 0)
const showEmptyState = forceEmptyState || (!hasGroupedSessions && !hasTreeSessions && sessions.length === 0)
const dndActive = sortable && !!onReorder
const renderRow = (session: SessionInfo) => {
@ -1234,16 +1293,17 @@ function SidebarSessionsSection({
const renderNestedSessionList = (items: SessionInfo[]) =>
dndActive ? (
<DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
<ReorderContext onReorder={onReorder} sensors={dndSensors}>
<SortableContext items={items.map(s => s.id)} strategy={verticalListSortingStrategy}>
{renderRows(items)}
</SortableContext>
</DndContext>
</ReorderContext>
) : (
renderRows(items)
)
const flatVirtualized = !showEmptyState && !groups?.length && sessions.length >= VIRTUALIZE_THRESHOLD
const flatVirtualized =
!showEmptyState && !groups?.length && !tree?.length && sessions.length >= VIRTUALIZE_THRESHOLD
let inner: React.ReactNode
let bodyOwnsDndContext = dndActive && !showEmptyState
@ -1251,6 +1311,36 @@ function SidebarSessionsSection({
if (showEmptyState) {
inner = emptyState
bodyOwnsDndContext = false
} else if (tree?.length) {
const parentNodes = tree.map(parent =>
dndActive ? (
<SortableSidebarWorkspaceParent
key={parent.id}
onNewSession={onNewSessionInWorkspace}
parent={parent}
renderRows={renderSessionList}
sortableGroups
/>
) : (
<SidebarWorkspaceParent
key={parent.id}
onNewSession={onNewSessionInWorkspace}
parent={parent}
renderRows={renderSessionList}
/>
)
)
inner = dndActive ? (
<ReorderContext onReorder={onReorder} sensors={dndSensors}>
<SortableContext items={tree.map(parent => parentDndId(parent.id))} strategy={verticalListSortingStrategy}>
{parentNodes}
</SortableContext>
</ReorderContext>
) : (
parentNodes
)
bodyOwnsDndContext = false
} else if (groups?.length) {
const groupNodes = groups.map(group =>
dndActive ? (
@ -1271,11 +1361,11 @@ function SidebarSessionsSection({
)
inner = dndActive ? (
<DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
<ReorderContext onReorder={onReorder} sensors={dndSensors}>
<SortableContext items={groups.map(g => groupDndId(g.id))} strategy={verticalListSortingStrategy}>
{groupNodes}
</SortableContext>
</DndContext>
</ReorderContext>
) : (
groupNodes
)
@ -1300,9 +1390,9 @@ function SidebarSessionsSection({
}
const body = bodyOwnsDndContext ? (
<DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
<ReorderContext onReorder={onReorder} sensors={dndSensors}>
{inner}
</DndContext>
</ReorderContext>
) : (
inner
)
@ -1383,7 +1473,14 @@ function SidebarWorkspaceGroup({
return (
<div
className={cn(
'grid gap-px data-[dragging=true]:z-10 data-[dragging=true]:opacity-70 data-[dragging=true]:will-change-transform',
// While lifted, paint the opaque sidebar surface so the dragged group
// erases the rows it floats over instead of ghosting them through a
// translucent body.
// minmax(0,1fr): pin the single column to the rail width. A bare `grid`
// auto column sizes to the widest child's MAX-content (the full,
// untruncated label), overflowing the rail so overflow-x-hidden clips the
// +/grabber off-screen — the inner truncate never gets a bounded width.
'grid grid-cols-[minmax(0,1fr)] gap-px data-[dragging=true]:z-10 data-[dragging=true]:rounded-md data-[dragging=true]:bg-(--ui-sidebar-surface-background) data-[dragging=true]:will-change-transform',
className
)}
data-dragging={dragging ? 'true' : undefined}
@ -1393,7 +1490,7 @@ function SidebarWorkspaceGroup({
>
<div className="group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem] font-medium text-(--ui-text-tertiary)">
<button
className="flex min-w-0 items-center gap-1.5 bg-transparent text-left hover:text-(--ui-text-secondary)"
className="flex min-w-0 flex-1 items-center gap-1.5 bg-transparent text-left hover:text-(--ui-text-secondary)"
onClick={() => setOpen(value => !value)}
type="button"
>
@ -1411,12 +1508,14 @@ function SidebarWorkspaceGroup({
platformName={group.label}
/>
) : null}
<span className="truncate">{group.label}</span>
<SidebarCount>
{isProfileGroup ? countLabel(visibleSessions.length, totalCount) : group.sessions.length}
</SidebarCount>
<span className="min-w-0 truncate">{group.label}</span>
<span className="shrink-0">
<SidebarCount>
{isProfileGroup ? countLabel(visibleSessions.length, totalCount) : group.sessions.length}
</SidebarCount>
</span>
<DisclosureCaret
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
</button>
@ -1464,16 +1563,11 @@ function SidebarWorkspaceGroup({
step={nextCount}
/>
) : (
<Tip label={s.showMoreIn(nextCount, group.label)}>
<button
aria-label={s.showMoreIn(nextCount, group.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"
onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
type="button"
>
<Codicon name="ellipsis" size="0.75rem" />
</button>
</Tip>
<WorkspaceShowMoreButton
count={nextCount}
label={group.label}
onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
/>
))}
</>
)}
@ -1491,10 +1585,178 @@ function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) {
return <SidebarWorkspaceGroup {...props} {...useSortableBindings(groupDndId(props.group.id))} />
}
interface SidebarWorkspaceParentProps extends React.ComponentProps<'div'> {
parent: SidebarWorkspaceTree
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
// Whether the worktrees inside this parent reorder (wired to a SortableContext).
sortableGroups?: boolean
// Whether this parent itself is draggable (set by useSortableBindings).
reorderable?: boolean
dragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLElement>
}
// Top level of the worktree tree: a repo header whose body is the repo's
// worktrees (each a SidebarWorkspaceGroup), indented one step.
function SidebarWorkspaceParent({
parent,
renderRows,
onNewSession,
sortableGroups = false,
reorderable = false,
dragging = false,
dragHandleProps,
className,
style,
ref,
...rest
}: SidebarWorkspaceParentProps) {
const { t } = useI18n()
const s = t.sidebar
const [open, setOpen] = useState(true)
const [visibleCount, setVisibleCount] = useState(WORKSPACE_PAGE)
// A repo with a single worktree has no second level worth showing: collapse it
// to one row (repo header → its sessions directly), only nesting when there
// are 2+ worktrees to choose between.
const soleWorktree = parent.groups.length === 1 ? parent.groups[0] : null
const newSessionPath = soleWorktree ? soleWorktree.path : parent.path
const visibleSessions = soleWorktree ? soleWorktree.sessions.slice(0, visibleCount) : []
const hiddenCount = soleWorktree ? Math.max(0, soleWorktree.sessions.length - visibleSessions.length) : 0
const groupNodes = parent.groups.map(group =>
sortableGroups ? (
<SortableSidebarWorkspaceGroup group={group} key={group.id} onNewSession={onNewSession} renderRows={renderRows} />
) : (
<SidebarWorkspaceGroup group={group} key={group.id} onNewSession={onNewSession} renderRows={renderRows} />
)
)
return (
<div
className={cn(
'grid grid-cols-[minmax(0,1fr)] gap-px data-[dragging=true]:z-10 data-[dragging=true]:rounded-md data-[dragging=true]:bg-(--ui-sidebar-surface-background) data-[dragging=true]:will-change-transform',
className
)}
data-dragging={dragging ? 'true' : undefined}
ref={ref}
style={style}
{...rest}
>
<div className="group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem] font-semibold text-(--ui-text-secondary)">
<button
className="flex min-w-0 flex-1 items-center gap-1.5 bg-transparent text-left hover:text-foreground"
onClick={() => setOpen(value => !value)}
type="button"
>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="repo" size="0.75rem" />
<span className="min-w-0 truncate">{parent.label}</span>
<span className="shrink-0">
<SidebarCount>{parent.sessionCount}</SidebarCount>
</span>
<DisclosureCaret
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
</button>
{onNewSession && (newSessionPath || soleWorktree) && (
<Tip label={s.newSessionIn(parent.label)}>
<button
aria-label={s.newSessionIn(parent.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={() => onNewSession?.(newSessionPath)}
type="button"
>
<Codicon name="add" size="0.75rem" />
</button>
</Tip>
)}
{reorderable && (
<span
{...dragHandleProps}
aria-label={s.reorderWorkspace(parent.label)}
className="ml-auto -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing"
onClick={event => event.stopPropagation()}
>
<Codicon
className={cn(
'text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/workspace:opacity-80 hover:text-(--ui-text-secondary)',
dragging && 'text-(--ui-text-secondary) opacity-100'
)}
name="grabber"
size="0.75rem"
/>
</span>
)}
</div>
{open &&
(soleWorktree ? (
// Collapsed: the repo's sessions hang straight off the header.
<>
{renderRows(visibleSessions)}
{hiddenCount > 0 && (
<WorkspaceShowMoreButton
count={Math.min(WORKSPACE_PAGE, hiddenCount)}
label={parent.label}
onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
/>
)}
</>
) : (
// Indent the worktrees under their repo; keep the column pinned to the
// rail so long branch labels truncate instead of shoving controls off.
<div className="grid grid-cols-[minmax(0,1fr)] gap-px pl-2.5">
{sortableGroups ? (
<SortableContext
items={parent.groups.map(group => groupDndId(group.id))}
strategy={verticalListSortingStrategy}
>
{groupNodes}
</SortableContext>
) : (
groupNodes
)}
</div>
))}
</div>
)
}
interface SortableWorkspaceParentProps {
parent: SidebarWorkspaceTree
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
sortableGroups?: boolean
}
function SortableSidebarWorkspaceParent(props: SortableWorkspaceParentProps) {
return <SidebarWorkspaceParent {...props} {...useSortableBindings(parentDndId(props.parent.id))} />
}
function SidebarCount({ children }: { children: React.ReactNode }) {
return <span className="text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{children}</span>
}
// Reveals the next page of already-loaded rows within a workspace/worktree.
function WorkspaceShowMoreButton({ count, label, onClick }: { count: number; label: string; onClick: () => void }) {
const { t } = useI18n()
const text = t.sidebar.showMoreIn(count, label)
return (
<Tip label={text}>
<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>
</Tip>
)
}
interface SortableSessionRowProps {
session: SessionInfo
isPinned: boolean

View file

@ -96,7 +96,9 @@ export function SidebarSessionRow({
'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',
isSelected && 'bg-(--ui-row-active-background)',
isWorking && 'text-foreground',
dragging && 'z-10 cursor-grabbing opacity-60 shadow-sm',
// Opaque surface while lifted so the dragged row erases what's under
// it (translucency let the rows below bleed through).
dragging && 'z-10 cursor-grabbing bg-(--ui-sidebar-surface-background)',
className
)}
data-working={isWorking ? 'true' : undefined}

View file

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

View file

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

View file

@ -333,7 +333,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
cursorBlink: true,
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace",
fontSize: 11,
fontWeight: '400',
fontWeight: '500',
fontWeightBold: '700',
letterSpacing: 0,
lineHeight: 1.12,
@ -617,10 +617,12 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
startSession()
}
// fonts.ready settles only already-requested faces; bold/italic aren't asked
// for until styled output paints (past atlas init), so warm them up front.
// fonts.ready settles only already-requested faces; the regular (500),
// bold (700) and italic aren't asked for until styled output paints (past
// atlas init), so warm them up front — otherwise the WebGL atlas bakes a
// fallback face and the terminal renders thin until a repaint.
const warm = document.fonts?.load
? Promise.allSettled(['400', '700', 'italic 400'].map(v => document.fonts.load(`${v} 11px 'JetBrains Mono'`)))
? Promise.allSettled(['500', '700', 'italic 500'].map(v => document.fonts.load(`${v} 11px 'JetBrains Mono'`)))
: Promise.resolve()
void warm.then(mount, mount)

View file

@ -81,6 +81,7 @@ import {
import { Loader } from '@/components/ui/loader'
import type { HermesGateway } from '@/hermes'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useStuckToTop } from '@/hooks/use-stuck-to-top'
import { useI18n } from '@/i18n'
import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runtime'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
@ -708,11 +709,18 @@ function messageAttachmentRefs(value: unknown): string[] {
}
function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDivElement | null>(null)
// --sticky-human-top is 0.23rem (~4px); the sentinel trips when the bubble
// parks there. Collapses sticky attachments via [data-stuck] (see styles.css).
const stuck = useStuckToTop(ref, 4)
return (
<div
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-2"
data-role="user"
data-slot="aui_user-message-root"
data-stuck={stuck ? 'true' : undefined}
ref={ref}
>
{children}
</div>
@ -857,8 +865,12 @@ const UserMessage: FC<{
const bubbleContent = (
<>
{/* Attachments collapse to nothing while the bubble rests (incl. stuck at
the top of the viewport) so a message with attachments doesn't eat the
screen; they expand with the body when the bubble is focused / the edit
composer opens (see styles.css .sticky-human-attachments). */}
{attachmentRefs.length > 0 && (
<span className="-mx-1 flex flex-wrap gap-1 border-b border-border/45 pb-1.5">
<span className="sticky-human-attachments -mx-1 flex flex-wrap gap-1 border-b border-border/45 pb-1.5">
<DirectiveContent text={attachmentRefs.join(' ')} />
</span>
)}

View file

@ -51,7 +51,9 @@ export function StatusRow({
role={onActivate ? 'button' : undefined}
tabIndex={onActivate ? 0 : undefined}
>
<span className="flex size-3.5 shrink-0 items-center justify-center">{leading}</span>
{leading !== undefined && (
<span className="flex size-3.5 shrink-0 items-center justify-center">{leading}</span>
)}
<div className="flex min-w-0 flex-1 items-center gap-2">{children}</div>
{trailing && (
<div

Binary file not shown.

View file

@ -69,6 +69,10 @@ declare global {
getRecentLogs: () => Promise<{ path: string; lines: string[] }>
readDir: (path: string) => Promise<HermesReadDirResult>
gitRoot?: (path: string) => Promise<string | null>
// Resolve git-worktree identity for a batch of session cwds, reading git's
// on-disk metadata locally. Returns null per cwd that isn't inside a
// checkout (or can't be read — e.g. a remote backend's path).
worktrees?: (cwds: string[]) => Promise<Record<string, HermesWorktreeInfo | null>>
terminal: {
dispose: (id: string) => Promise<boolean>
onData: (id: string, callback: (payload: string) => void) => () => void
@ -441,6 +445,18 @@ export interface HermesPreviewWatch {
path: string
}
export interface HermesWorktreeInfo {
// Main repo root — the shared grouping key for a checkout and all its linked
// worktrees.
repoRoot: string
// This cwd's own worktree root.
worktreeRoot: string
// True when this is the repo's primary checkout (.git is a directory).
isMainWorktree: boolean
// Current branch (or short detached-HEAD sha), null when unreadable.
branch: null | string
}
export interface HermesReadDirEntry {
name: string
path: string

View file

@ -0,0 +1,60 @@
import { type RefObject, useEffect, useState } from 'react'
/** Nearest scrollable ancestor (the IntersectionObserver root). */
function scrollParent(el: Element | null): Element | null {
let node = el?.parentElement ?? null
while (node) {
const overflowY = getComputedStyle(node).overflowY
if (overflowY === 'auto' || overflowY === 'scroll') {
return node
}
node = node.parentElement
}
return null
}
/**
* True while `ref` is pinned at the top of its scroll container by
* `position: sticky`. Detects it with a zero-height sentinel inserted just
* above the element: once the sentinel scrolls out under the sticky offset, the
* element is stuck. `stickyTopPx` is the element's `top` offset so the sentinel
* trips exactly when the element parks. CSS-native no scroll/pointer math.
*/
export function useStuckToTop(ref: RefObject<HTMLElement | null>, stickyTopPx = 0): boolean {
const [stuck, setStuck] = useState(false)
useEffect(() => {
const el = ref.current
if (!el || typeof IntersectionObserver === 'undefined') {
return
}
const root = scrollParent(el)
const sentinel = document.createElement('div')
sentinel.setAttribute('aria-hidden', 'true')
sentinel.style.cssText = 'position:absolute;top:0;left:0;height:1px;width:1px;pointer-events:none;'
el.style.position ||= 'relative'
el.prepend(sentinel)
const observer = new IntersectionObserver(
([entry]) => setStuck(entry.intersectionRatio === 0),
// Pull the root's top edge down by the sticky offset so the sentinel
// leaves the observed band exactly when the element parks.
{ root, rootMargin: `-${stickyTopPx + 1}px 0px 0px 0px`, threshold: [0, 1] }
)
observer.observe(sentinel)
return () => {
observer.disconnect()
sentinel.remove()
}
}, [ref, stickyTopPx])
return stuck
}

View file

@ -0,0 +1,68 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { uniqueCwds, type WorktreeResolver } from '@/app/chat/sidebar/workspace-groups'
import type { HermesWorktreeInfo } from '@/global'
import type { SessionInfo } from '@/hermes'
import { desktopFsCacheKey, desktopWorktrees } from '@/lib/desktop-fs'
type WorktreeMap = Record<string, HermesWorktreeInfo | null>
/**
* Probe the local filesystem for the git-worktree identity of each session cwd
* and return a resolver the grouping uses to build `parent → worktree`. Results
* are cached per cwd (and reset when the backend connection changes), so a probe
* runs once per directory. Unresolved cwds (probe pending, remote backend, or
* non-git dirs) fall back to the path-name heuristic in `workspaceTreeFor`.
*/
export function useWorktreeInfo(sessions: SessionInfo[], enabled: boolean): WorktreeResolver {
const [map, setMap] = useState<WorktreeMap>({})
const cacheRef = useRef<{ data: WorktreeMap; key: string }>({ data: {}, key: '' })
useEffect(() => {
if (!enabled) {
return
}
const key = desktopFsCacheKey()
if (cacheRef.current.key !== key) {
cacheRef.current = { data: {}, key }
setMap({})
}
const missing = uniqueCwds(sessions).filter(cwd => !(cwd in cacheRef.current.data))
if (!missing.length) {
return
}
let cancelled = false
void desktopWorktrees(missing)
.then(result => {
if (cancelled) {
return
}
// Record every probed cwd (null when absent) so we never re-probe it.
const next: WorktreeMap = { ...cacheRef.current.data }
for (const cwd of missing) {
next[cwd] = result[cwd] ?? null
}
cacheRef.current = { data: next, key }
setMap(next)
})
.catch(() => {
// Bridge unavailable / probe failed — leave cwds unresolved so the
// heuristic fallback handles them.
})
return () => {
cancelled = true
}
}, [sessions, enabled])
return useMemo<WorktreeResolver>(() => (cwd: string) => map[cwd], [map])
}

View file

@ -1,7 +1,12 @@
import type {
HermesConnection,
HermesReadDirResult,
HermesReadFileTextResult,
HermesSelectPathsOptions,
HermesWorktreeInfo
} from '@/global'
import { $connection } from '@/store/session'
import type { HermesConnection, HermesReadDirResult, HermesReadFileTextResult, HermesSelectPathsOptions } from '@/global'
export interface DesktopFsRemotePicker {
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
}
@ -75,6 +80,19 @@ export async function desktopGitRoot(path: string): Promise<string | null> {
return result.root
}
// Worktree detection runs against the LOCAL filesystem (the electron main
// process). For a remote backend the session cwds live on another machine, so
// we can't resolve them here — callers fall back to the path-name heuristic.
export async function desktopWorktrees(cwds: string[]): Promise<Record<string, HermesWorktreeInfo | null>> {
if (isDesktopFsRemoteMode()) {
return {}
}
const desktop = bridge()
return desktop.worktrees ? desktop.worktrees(cwds) : {}
}
export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string } | null> {
if (!isDesktopFsRemoteMode()) {
return null

View file

@ -26,6 +26,7 @@ const SIDEBAR_CRON_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarCronOpen'
const SIDEBAR_MESSAGING_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarMessagingOpen'
const SIDEBAR_SESSION_ORDER_STORAGE_KEY = 'hermes.desktop.sessionOrder'
const SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY = 'hermes.desktop.workspaceOrder'
const SIDEBAR_WORKSPACE_PARENT_ORDER_STORAGE_KEY = 'hermes.desktop.workspaceParentOrder'
const PANES_FLIPPED_STORAGE_KEY = 'hermes.desktop.panesFlipped'
export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar'
@ -58,6 +59,9 @@ export const $sidebarWidth: ReadableAtom<number> = computed($paneStates, states
export const $pinnedSessionIds = atom(storedStringArray(SIDEBAR_PINNED_STORAGE_KEY))
export const $sidebarSessionOrderIds = atom(storedStringArray(SIDEBAR_SESSION_ORDER_STORAGE_KEY))
export const $sidebarWorkspaceOrderIds = atom(storedStringArray(SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY))
// Order of the top-level repo "parent" groups in the worktree tree (worktrees
// within a parent reuse $sidebarWorkspaceOrderIds).
export const $sidebarWorkspaceParentOrderIds = atom(storedStringArray(SIDEBAR_WORKSPACE_PARENT_ORDER_STORAGE_KEY))
export const $sidebarPinsOpen = atom(true)
// Set by the PaneShell hover-reveal overlay while the sidebar is collapsed; kept
// true the whole time it's a floating overlay (not just while shown) so the
@ -85,6 +89,9 @@ $sidebarCronOpen.subscribe(open => persistBoolean(SIDEBAR_CRON_OPEN_STORAGE_KEY,
$sidebarMessagingOpenIds.subscribe(ids => persistStringArray(SIDEBAR_MESSAGING_OPEN_STORAGE_KEY, [...ids]))
$sidebarSessionOrderIds.subscribe(ids => persistStringArray(SIDEBAR_SESSION_ORDER_STORAGE_KEY, [...ids]))
$sidebarWorkspaceOrderIds.subscribe(ids => persistStringArray(SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY, [...ids]))
$sidebarWorkspaceParentOrderIds.subscribe(ids =>
persistStringArray(SIDEBAR_WORKSPACE_PARENT_ORDER_STORAGE_KEY, [...ids])
)
$sidebarAgentsGrouped.subscribe(grouped => persistBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, grouped))
$panesFlipped.subscribe(flipped => persistBoolean(PANES_FLIPPED_STORAGE_KEY, flipped))
@ -169,6 +176,12 @@ export function setSidebarWorkspaceOrderIds(ids: string[]) {
}
}
export function setSidebarWorkspaceParentOrderIds(ids: string[]) {
if (!arraysEqual($sidebarWorkspaceParentOrderIds.get(), ids)) {
$sidebarWorkspaceParentOrderIds.set(ids)
}
}
export function setSidebarResizing(resizing: boolean) {
$isSidebarResizing.set(resizing)
}

View file

@ -26,6 +26,13 @@
font-display: swap;
src: url('./fonts/JetBrainsMono-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('./fonts/JetBrainsMono-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
@ -419,6 +426,10 @@
body,
#root {
height: 100%;
/* App shell, not a document: the window itself never scrolls on either axis
(panes own their own scroll). Belt to the auto-scroll axis-lock in the
sidebar reorder DnD nothing can drag the whole shell sideways. */
overflow: hidden;
}
html {
@ -433,7 +444,6 @@
font-size: 0.8125rem;
line-height: var(--dt-line-height, 1.55);
letter-spacing: var(--dt-letter-spacing, 0);
overflow: hidden;
-webkit-user-select: none;
user-select: none;
-webkit-font-smoothing: antialiased;
@ -915,6 +925,17 @@ canvas {
mask-image: none;
}
/* Attachment chips sit above the clamped text. They render normally in flow,
but collapse to nothing while the bubble is stuck to the top of the viewport
(data-stuck, set by an IntersectionObserver sentinel) so a prompt with
attachments can't eat the screen as you scroll past it during a stream. */
[data-stuck='true'] .sticky-human-attachments {
max-height: 0;
overflow: hidden;
border-bottom-width: 0;
padding-bottom: 0;
}
/* The thread renders items in natural document flow (padding spacers, not
transforms) and @tanstack/react-virtual already adjusts scrollTop itself
when an off-screen turn is measured and its real height differs from the