* feat(desktop): session hygiene, archive, media streaming + connecting overlay
Address a batch of desktop feedback:
- Stop leaking empty "Untitled" sessions: the TUI gateway pre-created a DB
row on every session.create (i.e. every launch/draft). Persist the row
lazily on first prompt instead, and hide message-less rows in the sidebar.
- Archive/hide sessions: new `archived` column + set_session_archived, web
API (`?archived=` + PATCH archived), Ctrl/⌘-click and a context-menu item
in the sidebar, and an "Archived Chats" settings panel to restore/delete.
- Videos load via a streaming `hermes-media://` protocol instead of capped,
in-memory data URLs (16 MB limit) — bypasses the cap and supports seeking.
- Background-process completions route to the session that launched them:
the completion event now carries session_key and each poller only consumes
its own.
- Sidebar: "Group by workspace" toggle is always visible; each workspace
group gets a "+" to start a session in that directory; "New agent"/"Agents"
relabeled to "New session"/"Sessions".
- New gateway connecting overlay (ascii decode → fade out) replacing the bare
skeleton/"starting gateway" state.
* fix(desktop): bail connecting overlay on boot error
The shownRef latch kept the connecting overlay mounted behind
BootFailureOverlay after a hard boot failure. Return null on boot.error
so the failure recovery surface fully owns the screen.
* fix(desktop): address Copilot review
- /api/sessions: validate `archived` (400 on unknown) and return `archived`
as a JSON boolean instead of SQLite's 0/1.
- PATCH /api/sessions/{id}: 400 (not a misleading 404) when the body has no
updatable fields; stop conflating a no-op with "not found".
- hermes-media protocol: drop `bypassCSP` — streaming only needs
secure/standard/stream/supportFetchAPI.
- Sidebar workspace header: split the toggle and the "+" into sibling buttons
so we no longer nest interactive elements inside a <button>.
* fix(desktop): address Copilot re-review
- hermes-media protocol: restrict streaming to an audio/video extension
allowlist (415 otherwise) so it can't be used to read arbitrary local files.
- Connecting overlay: use z-[1200] instead of the non-standard z-1200 utility.
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
154 lines
4.3 KiB
TypeScript
154 lines
4.3 KiB
TypeScript
import { SortableContext, useSortable, verticalListSortingStrategy } 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 { SessionInfo } from '@/hermes'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
import { SidebarSessionRow } from './session-row'
|
|
|
|
interface SessionRowCommonProps {
|
|
isPinned: boolean
|
|
isSelected: boolean
|
|
isWorking: boolean
|
|
onArchive: () => void
|
|
onDelete: () => void
|
|
onPin: () => void
|
|
onResume: () => void
|
|
}
|
|
|
|
interface VirtualSessionListProps {
|
|
activeSessionId: null | string
|
|
className?: string
|
|
onArchiveSession: (sessionId: string) => void
|
|
onDeleteSession: (sessionId: string) => void
|
|
onResumeSession: (sessionId: string) => void
|
|
onTogglePin: (sessionId: string) => void
|
|
pinned: boolean
|
|
sessions: SessionInfo[]
|
|
sortable: boolean
|
|
workingSessionIdSet: Set<string>
|
|
}
|
|
|
|
const ROW_ESTIMATE_PX = 28
|
|
const OVERSCAN_ROWS = 12
|
|
|
|
export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
|
activeSessionId,
|
|
className,
|
|
onArchiveSession,
|
|
onDeleteSession,
|
|
onResumeSession,
|
|
onTogglePin,
|
|
pinned,
|
|
sessions,
|
|
sortable,
|
|
workingSessionIdSet
|
|
}) => {
|
|
const scrollerRef = useRef<HTMLDivElement | null>(null)
|
|
const ids = useMemo(() => sessions.map(s => s.id), [sessions])
|
|
|
|
const virtualizer = useVirtualizer({
|
|
count: sessions.length,
|
|
estimateSize: () => ROW_ESTIMATE_PX,
|
|
getItemKey: index => sessions[index]?.id ?? index,
|
|
getScrollElement: () => scrollerRef.current,
|
|
// jsdom-friendly default; the real rect takes over on first observe.
|
|
initialRect: { height: 600, width: 240 },
|
|
overscan: OVERSCAN_ROWS
|
|
})
|
|
|
|
const virtualItems = virtualizer.getVirtualItems()
|
|
const totalSize = virtualizer.getTotalSize()
|
|
const paddingTop = virtualItems[0]?.start ?? 0
|
|
const paddingBottom = Math.max(0, totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0))
|
|
|
|
const rows = virtualItems.map(virtualItem => {
|
|
const session = sessions[virtualItem.index]
|
|
|
|
if (!session) {
|
|
return null
|
|
}
|
|
|
|
const commonProps: SessionRowCommonProps = {
|
|
isPinned: pinned,
|
|
isSelected: session.id === activeSessionId,
|
|
isWorking: workingSessionIdSet.has(session.id),
|
|
onArchive: () => onArchiveSession(session.id),
|
|
onDelete: () => onDeleteSession(session.id),
|
|
onPin: () => onTogglePin(session.id),
|
|
onResume: () => onResumeSession(session.id)
|
|
}
|
|
|
|
return sortable ? (
|
|
<VirtualSortableRow
|
|
index={virtualItem.index}
|
|
key={session.id}
|
|
measureRef={virtualizer.measureElement}
|
|
rowProps={commonProps}
|
|
session={session}
|
|
/>
|
|
) : (
|
|
<SidebarSessionRow
|
|
{...commonProps}
|
|
data-index={virtualItem.index}
|
|
key={session.id}
|
|
ref={virtualizer.measureElement}
|
|
session={session}
|
|
/>
|
|
)
|
|
})
|
|
|
|
const list = (
|
|
<div className={cn('relative min-h-0 flex-1 overflow-y-auto overscroll-contain', className)} ref={scrollerRef}>
|
|
<div className="grid gap-px" style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}>
|
|
{rows}
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
return sortable ? (
|
|
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
|
{list}
|
|
</SortableContext>
|
|
) : (
|
|
list
|
|
)
|
|
}
|
|
|
|
interface VirtualSortableRowProps {
|
|
index: number
|
|
measureRef: (node: Element | null) => void
|
|
rowProps: SessionRowCommonProps
|
|
session: SessionInfo
|
|
}
|
|
|
|
function VirtualSortableRow({ index, measureRef, rowProps, session }: VirtualSortableRowProps) {
|
|
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id: session.id })
|
|
|
|
// Merge dnd-kit's setNodeRef with the virtualizer's measureElement so
|
|
// the row participates in both DnD hit-testing and TanStack height
|
|
// measurement.
|
|
const refMerged = useCallback(
|
|
(node: HTMLDivElement | null) => {
|
|
setNodeRef(node)
|
|
measureRef(node)
|
|
},
|
|
[measureRef, setNodeRef]
|
|
)
|
|
|
|
return (
|
|
<SidebarSessionRow
|
|
{...rowProps}
|
|
data-index={index}
|
|
dragging={isDragging}
|
|
dragHandleProps={{ ...attributes, ...listeners }}
|
|
ref={refMerged}
|
|
reorderable
|
|
session={session}
|
|
style={{ transform: CSS.Transform.toString(transform), transition }}
|
|
/>
|
|
)
|
|
}
|