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

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

Also: fuse the status stack to the composer border, restore icon actions
in the queue panel, fix sidebar label truncation and drag styling, hide
sticky-message attachments while pinned, and bump the terminal font.
This commit is contained in:
Brooklyn Nicholson
2026-06-12 18:18:39 -05:00
parent a118b94a85
commit e90672696e
21 changed files with 1298 additions and 140 deletions
@@ -0,0 +1,60 @@
import { type RefObject, useEffect, useState } from 'react'
/** Nearest scrollable ancestor (the IntersectionObserver root). */
function scrollParent(el: Element | null): Element | null {
let node = el?.parentElement ?? null
while (node) {
const overflowY = getComputedStyle(node).overflowY
if (overflowY === 'auto' || overflowY === 'scroll') {
return node
}
node = node.parentElement
}
return null
}
/**
* True while `ref` is pinned at the top of its scroll container by
* `position: sticky`. Detects it with a zero-height sentinel inserted just
* above the element: once the sentinel scrolls out under the sticky offset, the
* element is stuck. `stickyTopPx` is the element's `top` offset so the sentinel
* trips exactly when the element parks. CSS-native — no scroll/pointer math.
*/
export function useStuckToTop(ref: RefObject<HTMLElement | null>, stickyTopPx = 0): boolean {
const [stuck, setStuck] = useState(false)
useEffect(() => {
const el = ref.current
if (!el || typeof IntersectionObserver === 'undefined') {
return
}
const root = scrollParent(el)
const sentinel = document.createElement('div')
sentinel.setAttribute('aria-hidden', 'true')
sentinel.style.cssText = 'position:absolute;top:0;left:0;height:1px;width:1px;pointer-events:none;'
el.style.position ||= 'relative'
el.prepend(sentinel)
const observer = new IntersectionObserver(
([entry]) => setStuck(entry.intersectionRatio === 0),
// Pull the root's top edge down by the sticky offset so the sentinel
// leaves the observed band exactly when the element parks.
{ root, rootMargin: `-${stickyTopPx + 1}px 0px 0px 0px`, threshold: [0, 1] }
)
observer.observe(sentinel)
return () => {
observer.disconnect()
sentinel.remove()
}
}, [ref, stickyTopPx])
return stuck
}
@@ -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])
}