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. */} +
-
+
- {(onNewSession || isProfileGroup) && ( - - - - )} - {reorderable && ( - event.stopPropagation()} - > - - - )} -
+ ) + } + count={isProfileGroup ? countLabel(visibleSessions.length, totalCount) : group.sessions.length} + dragging={dragging} + dragHandleProps={dragHandleProps} + icon={leadingIcon} + label={group.label} + onToggle={() => setOpen(value => !value)} + open={open} + reorderable={reorderable} + /> {open && ( <> {renderRows(visibleSessions)} @@ -1464,16 +1447,11 @@ function SidebarWorkspaceGroup({ step={nextCount} /> ) : ( - - - + setVisibleCount(count => count + WORKSPACE_PAGE)} + /> ))} )} @@ -1488,13 +1466,281 @@ interface SortableWorkspaceProps { } function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) { - return + return +} + +interface SidebarWorkspaceParentProps extends React.ComponentProps<'div'> { + parent: SidebarWorkspaceTree + renderRows: (sessions: SessionInfo[]) => React.ReactNode + onNewSession?: (path: null | string) => void + // When set, this parent's worktrees reorder inside their OWN ReorderableList, so a + // worktree drag only ever collides with its siblings — never the repos around it. + onReorderWorktree?: (parentId: string, ids: string[]) => void + dndSensors?: ReturnType + // 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, + onReorderWorktree, + dndSensors, + 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 => + onReorderWorktree ? ( + + ) : ( + + ) + ) + + return ( +
+ onNewSession?.(newSessionPath)} /> + ) + } + count={parent.sessionCount} + dragging={dragging} + dragHandleProps={dragHandleProps} + emphasis + icon={} + label={parent.label} + onToggle={() => setOpen(value => !value)} + open={open} + reorderable={reorderable} + /> + {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. +
+ {onReorderWorktree ? ( + group.id)} + onReorder={ids => onReorderWorktree(parent.id, ids)} + sensors={dndSensors} + > + {groupNodes} + + ) : ( + groupNodes + )} +
+ ))} +
+ ) +} + +interface SortableWorkspaceParentProps { + parent: SidebarWorkspaceTree + renderRows: (sessions: SessionInfo[]) => React.ReactNode + onNewSession?: (path: null | string) => void + onReorderWorktree?: (parentId: string, ids: string[]) => void + dndSensors?: ReturnType +} + +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 ( + + + + ) +} + +// Reorder handle that lives in the header's leading-icon slot: the resting icon +// fades out and a grabber fades in on hover/drag (same swap as the session row), +// so the drag affordance never eats header width on the right. +function WorkspaceReorderHandle({ + dragHandleProps, + dragging, + icon, + label +}: { + dragHandleProps?: React.HTMLAttributes + dragging: boolean + icon: React.ReactNode + label: string +}) { + return ( + event.stopPropagation()} + > + + {icon} + + + + ) +} + +// "+" affordance shared by repo and worktree headers — reveals on header hover. +function WorkspaceAddButton({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + + + ) +} + +// Collapsible header shared by the repo (emphasis) and worktree levels: a +// toggle button whose leading glyph doubles as the reorder handle, plus an +// optional trailing action (the +). +function WorkspaceHeader({ + action, + count, + dragHandleProps, + dragging = false, + emphasis = false, + icon, + label, + onToggle, + open, + reorderable = false +}: { + action?: React.ReactNode + count: React.ReactNode + dragHandleProps?: React.HTMLAttributes + dragging?: boolean + emphasis?: boolean + icon: React.ReactNode + label: string + onToggle: () => void + open: boolean + reorderable?: boolean +}) { + const { t } = useI18n() + + return ( +
+ + {action} +
+ ) +} + 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/virtual-session-list.tsx b/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx index b2c6eff9f1c..5f82b305962 100644 --- a/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx +++ b/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx @@ -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 = ({ workingSessionIdSet }) => { const scrollerRef = useRef(null) - const ids = useMemo(() => sessions.map(s => s.id), [sessions]) const virtualizer = useVirtualizer({ count: sessions.length, @@ -101,21 +100,16 @@ export const VirtualSessionList: FC = ({ ) }) - 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 (
{rows}
) - - return sortable ? ( - - {list} - - ) : ( - list - ) } interface VirtualSortableRowProps { 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..1e5d4d275b7 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 @@ -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() diff --git a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx index c4208413849..03bc9082a46 100644 --- a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx @@ -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(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. diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 3703c91a5df..effeb38e79a 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -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 ( -
- {children} -
+ // 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. + <> +
+ {children} +
+ {attachments} + ) } @@ -855,29 +867,31 @@ const UserMessage: FC<{ 'border-(--ui-stroke-tertiary) hover:border-(--ui-stroke-secondary)' ) - const bubbleContent = ( - <> - {attachmentRefs.length > 0 && ( - - - - )} - {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. -
-
- -
-
- )} - + 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. +
+
+ +
+
) return ( - + 0 ? ( +
+ +
+ ) : null + } + >
@@ -1330,7 +1344,10 @@ const UserEditComposer: FC = ({ cwd, gateway, sessionId } } const remote = $connection.get()?.mode === 'remote' - const requestGateway = (method: string, params?: Record) => gateway.request(method, params) + + const requestGateway = (method: string, params?: Record) => + gateway.request(method, params) + const refs: InlineRefInput[] = [] for (const candidate of osDrops) { 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-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..46cdf0ede2a 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) } @@ -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) } } diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index b4a4d33f03f..f203aaf9976 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -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;