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:
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user