diff --git a/apps/desktop/electron/git-worktrees.cjs b/apps/desktop/electron/git-worktrees.cjs new file mode 100644 index 00000000000..570397b2c95 --- /dev/null +++ b/apps/desktop/electron/git-worktrees.cjs @@ -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: /.git/worktrees/`. +// That admin dir's `commondir` points back at the shared `/.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 `/.git`; fall back to walking two + // levels up from `/.git/worktrees/` 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 +} diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 85cf762e85f..8286630b954 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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.') diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index dce31fc8db6..11292e4cd89 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -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), diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index b44a7ec976c..43074b5ce37 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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" diff --git a/apps/desktop/src/app/chat/composer/queue-panel.tsx b/apps/desktop/src/app/chat/composer/queue-panel.tsx index 9ed2bfb4fa1..fb4365506c0 100644 --- a/apps/desktop/src/app/chat/composer/queue-panel.tsx +++ b/apps/desktop/src/app/chat/composer/queue-panel.tsx @@ -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={ - - } trailing={ <> - - - + + + + + + + + + } trailingVisible={isEditing} diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index cc744e0aae8..a13e039ecc6 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -170,14 +170,22 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro return (
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. */} -
+ 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. */} +
-
+
@@ -1464,16 +1563,11 @@ function SidebarWorkspaceGroup({ step={nextCount} /> ) : ( - - - + setVisibleCount(count => count + WORKSPACE_PAGE)} + /> ))} )} @@ -1491,10 +1585,178 @@ function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) { return } +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 +} + +// 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 ? ( + + ) : ( + + ) + ) + + return ( +
+
+ + {onNewSession && (newSessionPath || soleWorktree) && ( + + + + )} + {reorderable && ( + event.stopPropagation()} + > + + + )} +
+ {open && + (soleWorktree ? ( + // Collapsed: the repo's sessions hang straight off the header. + <> + {renderRows(visibleSessions)} + {hiddenCount > 0 && ( + 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. +
+ {sortableGroups ? ( + groupDndId(group.id))} + strategy={verticalListSortingStrategy} + > + {groupNodes} + + ) : ( + groupNodes + )} +
+ ))} +
+ ) +} + +interface SortableWorkspaceParentProps { + parent: SidebarWorkspaceTree + renderRows: (sessions: SessionInfo[]) => React.ReactNode + onNewSession?: (path: null | string) => void + sortableGroups?: boolean +} + +function SortableSidebarWorkspaceParent(props: SortableWorkspaceParentProps) { + return +} + function SidebarCount({ children }: { children: React.ReactNode }) { return {children} } +// 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 ( + + + + ) +} + interface SortableSessionRowProps { session: SessionInfo isPinned: boolean diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index cd21a63a6f9..e476237d202 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -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} diff --git a/apps/desktop/src/app/chat/sidebar/workspace-groups.test.ts b/apps/desktop/src/app/chat/sidebar/workspace-groups.test.ts new file mode 100644 index 00000000000..f626ebbb3b4 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/workspace-groups.test.ts @@ -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 { + 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 & Pick): HermesWorktreeInfo => ({ + branch: null, + isMainWorktree: false, + ...over +}) + +describe('workspaceTreeFor', () => { + it('heuristic nests `-wt-` 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']) + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/workspace-groups.ts b/apps/desktop/src/app/chat/sidebar/workspace-groups.ts new file mode 100644 index 00000000000..1eab5760101 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/workspace-groups.ts @@ -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() + + 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() + + 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() + + 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 `-wt-` directory + * nests under its sibling ``; 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() + + 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() + 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() + + 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 +} diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts index 199457e29af..64ba7c8ef48 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts @@ -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) diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 3703c91a5df..96b9ed9c2ce 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -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(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 (
{children}
@@ -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 && ( - + )} diff --git a/apps/desktop/src/components/chat/status-row.tsx b/apps/desktop/src/components/chat/status-row.tsx index 8d66bde51eb..ad4769c458f 100644 --- a/apps/desktop/src/components/chat/status-row.tsx +++ b/apps/desktop/src/components/chat/status-row.tsx @@ -51,7 +51,9 @@ export function StatusRow({ role={onActivate ? 'button' : undefined} tabIndex={onActivate ? 0 : undefined} > - {leading} + {leading !== undefined && ( + {leading} + )}
{children}
{trailing && (
Promise<{ path: string; lines: string[] }> readDir: (path: string) => Promise gitRoot?: (path: string) => Promise + // 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> terminal: { dispose: (id: string) => Promise 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 diff --git a/apps/desktop/src/hooks/use-stuck-to-top.ts b/apps/desktop/src/hooks/use-stuck-to-top.ts new file mode 100644 index 00000000000..ed1e139d737 --- /dev/null +++ b/apps/desktop/src/hooks/use-stuck-to-top.ts @@ -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, 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 +} diff --git a/apps/desktop/src/hooks/use-worktree-info.ts b/apps/desktop/src/hooks/use-worktree-info.ts new file mode 100644 index 00000000000..b981cf6ef3c --- /dev/null +++ b/apps/desktop/src/hooks/use-worktree-info.ts @@ -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 + +/** + * 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({}) + 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(() => (cwd: string) => map[cwd], [map]) +} diff --git a/apps/desktop/src/lib/desktop-fs.ts b/apps/desktop/src/lib/desktop-fs.ts index fab307d875d..b57701013e6 100644 --- a/apps/desktop/src/lib/desktop-fs.ts +++ b/apps/desktop/src/lib/desktop-fs.ts @@ -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 } @@ -75,6 +80,19 @@ export async function desktopGitRoot(path: string): Promise { 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> { + 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 diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts index b882608c7c9..27799435daf 100644 --- a/apps/desktop/src/store/layout.ts +++ b/apps/desktop/src/store/layout.ts @@ -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 = 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) } diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 42b781eb3cf..86b2205c6f8 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -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