mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-13 09:01:54 +00:00
Merge pull request #45273 from NousResearch/bb/sidebar-workspace-dedup
feat(desktop): worktree-aware sidebar grouping + composer/sidebar UX fixes
This commit is contained in:
commit
a86b7b314b
21 changed files with 1418 additions and 364 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',
|
||||
|
|
|
|||
|
|
@ -126,7 +126,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}
|
||||
|
|
@ -137,7 +143,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"
|
||||
>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { type FC, useCallback, useMemo, useRef } from 'react'
|
||||
import { type FC, useCallback, useRef } from 'react'
|
||||
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -48,7 +48,6 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
|||
workingSessionIdSet
|
||||
}) => {
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null)
|
||||
const ids = useMemo(() => sessions.map(s => s.id), [sessions])
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: sessions.length,
|
||||
|
|
@ -101,21 +100,16 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
|||
)
|
||||
})
|
||||
|
||||
const list = (
|
||||
// When sortable, the caller wraps this in a ReorderableList that owns the
|
||||
// DndContext + SortableContext (keyed on the same ids); the virtualized rows
|
||||
// just consume that context via useSortable.
|
||||
return (
|
||||
<div className={cn('relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain', className)} ref={scrollerRef}>
|
||||
<div className="grid gap-px" style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}>
|
||||
{rows}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return sortable ? (
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{list}
|
||||
</SortableContext>
|
||||
) : (
|
||||
list
|
||||
)
|
||||
}
|
||||
|
||||
interface VirtualSortableRowProps {
|
||||
|
|
|
|||
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
|
||||
}
|
||||
|
|
@ -328,13 +328,19 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
|||
|
||||
const term = new Terminal({
|
||||
allowProposedApi: true,
|
||||
allowTransparency: true,
|
||||
// Opaque canvas = WebGL's crisp fast-path. allowTransparency instead bakes
|
||||
// glyphs as grayscale-alpha for compositing over a see-through canvas, which
|
||||
// reads soft on every platform; VS Code keeps it off and our surface
|
||||
// (--ui-bg-chrome) is opaque anyway, so withSurface paints it solid.
|
||||
allowTransparency: false,
|
||||
convertEol: true,
|
||||
cursorBlink: true,
|
||||
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace",
|
||||
fontSize: 11,
|
||||
fontWeight: '400',
|
||||
fontWeightBold: '700',
|
||||
// VS Code's terminal renders 'normal'/'bold' (400/700); we were using Medium
|
||||
// (500) as the base, which reads a touch heavy at this size.
|
||||
fontWeight: 'normal',
|
||||
fontWeightBold: 'bold',
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1.12,
|
||||
// Full-screen TUIs (hermes --tui, vim) grab the mouse, so a plain drag
|
||||
|
|
@ -617,8 +623,10 @@ 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 (400),
|
||||
// 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.resolve()
|
||||
|
|
|
|||
|
|
@ -404,24 +404,10 @@ function useThreadScrollAnchor({
|
|||
}
|
||||
}, [scrollerRef, stickyBottomRef])
|
||||
|
||||
// Streaming auto-follow: while — and ONLY while — parked at the bottom, chase
|
||||
// content growth (streaming tokens, late measurement, Shiki re-highlight) so
|
||||
// the tail stays in view. One upward pixel (scroll/wheel/touch above) flips
|
||||
// the gate false and following stops until the user returns to the bottom.
|
||||
// Keyed on the virtualizer's own size signal and pinned in useLayoutEffect —
|
||||
// the virtualizer's scrollToFn runs in the same pre-paint pass, so the two
|
||||
// don't fight (no rubber-banding). pinToBottom no-ops at bottom, so rapid
|
||||
// growth is cheap.
|
||||
const totalSize = virtualizer.getTotalSize()
|
||||
const prevTotalSizeRef = useRef<number | null>(null)
|
||||
useLayoutEffect(() => {
|
||||
const prev = prevTotalSizeRef.current
|
||||
prevTotalSizeRef.current = totalSize
|
||||
|
||||
if (enabled && prev !== null && totalSize > prev && stickyBottomRef.current) {
|
||||
pinToBottom()
|
||||
}
|
||||
}, [enabled, pinToBottom, stickyBottomRef, totalSize])
|
||||
// No streaming auto-follow: chasing content growth while parked at the bottom
|
||||
// rubber-bands (the tail and the virtualizer's own measurement adjustments
|
||||
// fight for scrollTop). The one-time new-turn jump below already lands a fresh
|
||||
// message in view; from there the viewport stays put unless the user jumps.
|
||||
|
||||
// The floating jump button asks us to return to the bottom; same re-arm + pin
|
||||
// path as a new turn.
|
||||
|
|
|
|||
|
|
@ -52,7 +52,12 @@ import {
|
|||
} from '@/app/chat/composer/rich-editor'
|
||||
import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
|
||||
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
|
||||
import { extractDroppedFiles, HERMES_PATHS_MIME, isImagePath, partitionDroppedFiles } from '@/app/chat/hooks/use-composer-actions'
|
||||
import {
|
||||
extractDroppedFiles,
|
||||
HERMES_PATHS_MIME,
|
||||
isImagePath,
|
||||
partitionDroppedFiles
|
||||
} from '@/app/chat/hooks/use-composer-actions'
|
||||
import { uploadComposerAttachment } from '@/app/session/hooks/use-prompt-actions'
|
||||
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
||||
import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
|
|
@ -707,15 +712,22 @@ function messageAttachmentRefs(value: unknown): string[] {
|
|||
return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS
|
||||
}
|
||||
|
||||
function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
|
||||
function StickyHumanMessageContainer({ attachments, children }: { attachments?: ReactNode; children: ReactNode }) {
|
||||
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"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
// Fragment, not a wrapper: a wrapping element becomes the sticky's
|
||||
// containing block (it'd stick within its own height = never). The bubble
|
||||
// and attachments are flow siblings so the bubble pins against the scroller
|
||||
// while attachments below it scroll away.
|
||||
<>
|
||||
<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-1"
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{attachments}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -855,29 +867,31 @@ const UserMessage: FC<{
|
|||
'border-(--ui-stroke-tertiary) hover:border-(--ui-stroke-secondary)'
|
||||
)
|
||||
|
||||
const bubbleContent = (
|
||||
<>
|
||||
{attachmentRefs.length > 0 && (
|
||||
<span className="-mx-1 flex flex-wrap gap-1 border-b border-border/45 pb-1.5">
|
||||
<DirectiveContent text={attachmentRefs.join(' ')} />
|
||||
</span>
|
||||
)}
|
||||
{hasBody && (
|
||||
// Render the user's text through a minimal markdown pipeline:
|
||||
// backtick `code` and ``` fenced ``` blocks, with directive chips
|
||||
// (`@file:` etc.) still resolved inside the plain-text spans.
|
||||
<div className="sticky-human-clamp" data-clamped={bodyClamped ? 'true' : undefined}>
|
||||
<div ref={clampInnerRef}>
|
||||
<UserMessageText className="wrap-anywhere" text={messageText} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
const bubbleContent = hasBody && (
|
||||
// Render the user's text through a minimal markdown pipeline:
|
||||
// backtick `code` and ``` fenced ``` blocks, with directive chips
|
||||
// (`@file:` etc.) still resolved inside the plain-text spans.
|
||||
<div className="sticky-human-clamp" data-clamped={bodyClamped ? 'true' : undefined}>
|
||||
<div ref={clampInnerRef}>
|
||||
<UserMessageText className="wrap-anywhere" text={messageText} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root asChild>
|
||||
<StickyHumanMessageContainer>
|
||||
<StickyHumanMessageContainer
|
||||
attachments={
|
||||
// Attachments live BELOW the sticky bubble in normal flow, so they
|
||||
// scroll away behind the pinned bubble instead of riding along with
|
||||
// it. Image refs render as thumbnails, file refs as chips; no border.
|
||||
attachmentRefs.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1 -mt-3 mb-2">
|
||||
<DirectiveContent text={attachmentRefs.join(' ')} />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions">
|
||||
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
|
||||
<div className="relative w-full">
|
||||
|
|
@ -1330,7 +1344,10 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||
}
|
||||
|
||||
const remote = $connection.get()?.mode === 'remote'
|
||||
const requestGateway = <T,>(method: string, params?: Record<string, unknown>) => gateway.request<T>(method, params)
|
||||
|
||||
const requestGateway = <T,>(method: string, params?: Record<string, unknown>) =>
|
||||
gateway.request<T>(method, params)
|
||||
|
||||
const refs: InlineRefInput[] = []
|
||||
|
||||
for (const candidate of osDrops) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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
|
||||
|
|
|
|||
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)
|
||||
}
|
||||
|
|
@ -191,16 +204,15 @@ export function unpinSession(sessionId: string) {
|
|||
}
|
||||
}
|
||||
|
||||
export function reorderPinnedSession(sessionId: string, targetIndex: number) {
|
||||
// Replace the whole pinned order at once (drag-reorder hands back the new order
|
||||
// rather than a single move). Keep only ids that are actually pinned so a stale
|
||||
// row can't smuggle an unpinned id into the store.
|
||||
export function setPinnedSessionOrder(ids: string[]) {
|
||||
const prev = $pinnedSessionIds.get()
|
||||
const pinned = new Set(prev)
|
||||
const next = ids.filter(id => pinned.has(id))
|
||||
|
||||
if (!prev.includes(sessionId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = insertUniqueId(prev, sessionId, targetIndex)
|
||||
|
||||
if (!arraysEqual(prev, next)) {
|
||||
if (next.length === prev.length && !arraysEqual(prev, next)) {
|
||||
$pinnedSessionIds.set(next)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -419,6 +419,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 +437,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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue