mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-13 09:01:54 +00:00
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:
parent
a118b94a85
commit
e90672696e
21 changed files with 1298 additions and 140 deletions
174
apps/desktop/electron/git-worktrees.cjs
Normal file
174
apps/desktop/electron/git-worktrees.cjs
Normal 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
|
||||
}
|
||||
|
|
@ -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.')
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
149
apps/desktop/src/app/chat/sidebar/workspace-groups.test.ts
Normal file
149
apps/desktop/src/app/chat/sidebar/workspace-groups.test.ts
Normal 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 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'])
|
||||
})
|
||||
})
|
||||
326
apps/desktop/src/app/chat/sidebar/workspace-groups.ts
Normal file
326
apps/desktop/src/app/chat/sidebar/workspace-groups.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
BIN
apps/desktop/src/fonts/JetBrainsMono-Medium.woff2
Normal file
BIN
apps/desktop/src/fonts/JetBrainsMono-Medium.woff2
Normal file
Binary file not shown.
16
apps/desktop/src/global.d.ts
vendored
16
apps/desktop/src/global.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
60
apps/desktop/src/hooks/use-stuck-to-top.ts
Normal file
60
apps/desktop/src/hooks/use-stuck-to-top.ts
Normal 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
|
||||
}
|
||||
68
apps/desktop/src/hooks/use-worktree-info.ts
Normal file
68
apps/desktop/src/hooks/use-worktree-info.ts
Normal 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])
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue