feat(desktop): session hygiene, archive, media streaming + connecting overlay (#37099)
* 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>
This commit is contained in:
@ -8,6 +8,8 @@ const {
|
||||
ipcMain,
|
||||
nativeImage,
|
||||
nativeTheme,
|
||||
net: electronNet,
|
||||
protocol,
|
||||
safeStorage,
|
||||
session,
|
||||
shell,
|
||||
@ -364,6 +366,66 @@ app.setAboutPanelOptions({
|
||||
copyright: 'Copyright © 2026 Nous Research'
|
||||
})
|
||||
|
||||
// Custom scheme for streaming local media (video/audio) into the renderer.
|
||||
// Reading large media through `readFileDataUrl` failed: it base64-loads the
|
||||
// whole file into memory and is hard-capped at DATA_URL_READ_MAX_BYTES (16 MB),
|
||||
// so any non-trivial video silently refused to load. Streaming via a protocol
|
||||
// handler removes the size cap and gives the <video> element seekable,
|
||||
// range-aware playback. Must be registered before the app is ready.
|
||||
const MEDIA_PROTOCOL = 'hermes-media'
|
||||
// Only audio/video may be streamed. Without this the handler would read any
|
||||
// non-blocklisted local file (no size cap) for any `fetch(hermes-media://…)`.
|
||||
const STREAMABLE_MEDIA_EXTS = new Set([
|
||||
'.avi',
|
||||
'.flac',
|
||||
'.m4a',
|
||||
'.mkv',
|
||||
'.mov',
|
||||
'.mp3',
|
||||
'.mp4',
|
||||
'.ogg',
|
||||
'.opus',
|
||||
'.wav',
|
||||
'.webm'
|
||||
])
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: MEDIA_PROTOCOL,
|
||||
privileges: {
|
||||
secure: true,
|
||||
standard: true,
|
||||
stream: true,
|
||||
supportFetchAPI: true
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
function registerMediaProtocol() {
|
||||
protocol.handle(MEDIA_PROTOCOL, async request => {
|
||||
let resolvedPath
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const filePath = decodeURIComponent(url.pathname.replace(/^\/+/, ''))
|
||||
;({ resolvedPath } = await resolveReadableFileForIpc(filePath, { purpose: 'Media stream' }))
|
||||
} catch {
|
||||
return new Response('Media not found', { status: 404 })
|
||||
}
|
||||
|
||||
if (!STREAMABLE_MEDIA_EXTS.has(path.extname(resolvedPath).toLowerCase())) {
|
||||
return new Response('Unsupported media type', { status: 415 })
|
||||
}
|
||||
|
||||
// Delegate to Electron's net stack on a file:// URL — it resolves the
|
||||
// content-type and honors Range requests so seeking works. Forward the
|
||||
// renderer's headers (notably Range) and skip custom-protocol re-entry.
|
||||
return electronNet.fetch(pathToFileURL(resolvedPath).toString(), {
|
||||
bypassCustomProtocolHandlers: true,
|
||||
headers: request.headers
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
let mainWindow = null
|
||||
let hermesProcess = null
|
||||
let connectionPromise = null
|
||||
@ -3654,6 +3716,7 @@ app.whenReady().then(() => {
|
||||
Menu.setApplicationMenu(null)
|
||||
}
|
||||
installMediaPermissions()
|
||||
registerMediaProtocol()
|
||||
ensureWslWindowsFonts()
|
||||
createWindow()
|
||||
|
||||
|
||||
@ -97,7 +97,7 @@ function ChatHeader({
|
||||
const sessions = useStore($sessions)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
|
||||
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New agent'
|
||||
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session'
|
||||
const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
|
||||
|
||||
return (
|
||||
|
||||
@ -67,7 +67,12 @@ import { VirtualSessionList } from './virtual-session-list'
|
||||
const VIRTUALIZE_THRESHOLD = 25
|
||||
|
||||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
{ id: 'new-session', label: 'New agent', icon: props => <Codicon name="robot" {...props} />, action: 'new-session' },
|
||||
{
|
||||
id: 'new-session',
|
||||
label: 'New session',
|
||||
icon: props => <Codicon name="robot" {...props} />,
|
||||
action: 'new-session'
|
||||
},
|
||||
{ id: 'skills', label: 'Skills', icon: props => <Codicon name="symbol-misc" {...props} />, route: SKILLS_ROUTE },
|
||||
{ id: 'messaging', label: 'Messaging', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
|
||||
{ id: 'artifacts', label: 'Artifacts', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE }
|
||||
@ -149,6 +154,8 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
onLoadMoreSessions: () => void
|
||||
onResumeSession: (sessionId: string) => void
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
onArchiveSession: (sessionId: string) => void
|
||||
onNewSessionInWorkspace: (path: null | string) => void
|
||||
}
|
||||
|
||||
export function ChatSidebar({
|
||||
@ -156,7 +163,9 @@ export function ChatSidebar({
|
||||
onNavigate,
|
||||
onLoadMoreSessions,
|
||||
onResumeSession,
|
||||
onDeleteSession
|
||||
onDeleteSession,
|
||||
onArchiveSession,
|
||||
onNewSessionInWorkspace
|
||||
}: ChatSidebarProps) {
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const agentsGrouped = useStore($sidebarAgentsGrouped)
|
||||
@ -328,6 +337,7 @@ export function ChatSidebar({
|
||||
dndSensors={dndSensors}
|
||||
emptyState={<SidebarPinnedEmptyState />}
|
||||
label="Pinned"
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onReorder={handlePinnedDragEnd}
|
||||
onResumeSession={onResumeSession}
|
||||
@ -361,9 +371,9 @@ export function ChatSidebar({
|
||||
groups={agentsGrouped ? agentGroups : undefined}
|
||||
headerAction={
|
||||
<Button
|
||||
aria-label={agentsGrouped ? 'Show agents as a single list' : 'Group agents by workspace'}
|
||||
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
|
||||
className={cn(
|
||||
'cursor-pointer text-(--ui-text-tertiary) opacity-0 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100 group-hover/section:opacity-100',
|
||||
'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||
)}
|
||||
onClick={event => {
|
||||
@ -372,15 +382,17 @@ export function ChatSidebar({
|
||||
setSidebarAgentsGrouped(!agentsGrouped)
|
||||
}}
|
||||
size="icon-xs"
|
||||
title={agentsGrouped ? 'Ungroup agents' : 'Group by workspace'}
|
||||
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
|
||||
</Button>
|
||||
}
|
||||
label="Agents"
|
||||
label="Sessions"
|
||||
labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onNewSessionInWorkspace={onNewSessionInWorkspace}
|
||||
onReorder={handleAgentDragEnd}
|
||||
onResumeSession={onResumeSession}
|
||||
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
|
||||
@ -472,7 +484,9 @@ interface SidebarSessionsSectionProps {
|
||||
workingSessionIdSet: Set<string>
|
||||
onResumeSession: (sessionId: string) => void
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
onArchiveSession: (sessionId: string) => void
|
||||
onTogglePin: (sessionId: string) => void
|
||||
onNewSessionInWorkspace?: (path: null | string) => void
|
||||
pinned: boolean
|
||||
rootClassName?: string
|
||||
contentClassName?: string
|
||||
@ -496,7 +510,9 @@ function SidebarSessionsSection({
|
||||
workingSessionIdSet,
|
||||
onResumeSession,
|
||||
onDeleteSession,
|
||||
onArchiveSession,
|
||||
onTogglePin,
|
||||
onNewSessionInWorkspace,
|
||||
pinned,
|
||||
rootClassName,
|
||||
contentClassName,
|
||||
@ -518,6 +534,7 @@ function SidebarSessionsSection({
|
||||
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),
|
||||
@ -551,9 +568,19 @@ function SidebarSessionsSection({
|
||||
} else if (groups?.length) {
|
||||
const groupNodes = groups.map(group =>
|
||||
dndActive ? (
|
||||
<SortableSidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
|
||||
<SortableSidebarWorkspaceGroup
|
||||
group={group}
|
||||
key={group.id}
|
||||
onNewSession={onNewSessionInWorkspace}
|
||||
renderRows={renderSessionList}
|
||||
/>
|
||||
) : (
|
||||
<SidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
|
||||
<SidebarWorkspaceGroup
|
||||
group={group}
|
||||
key={group.id}
|
||||
onNewSession={onNewSessionInWorkspace}
|
||||
renderRows={renderSessionList}
|
||||
/>
|
||||
)
|
||||
)
|
||||
|
||||
@ -568,6 +595,7 @@ function SidebarSessionsSection({
|
||||
inner = (
|
||||
<VirtualSessionList
|
||||
activeSessionId={activeSessionId}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onResumeSession={onResumeSession}
|
||||
onTogglePin={onTogglePin}
|
||||
@ -610,6 +638,7 @@ function SidebarSessionsSection({
|
||||
interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {
|
||||
group: SidebarSessionGroup
|
||||
renderRows: (sessions: SessionInfo[]) => React.ReactNode
|
||||
onNewSession?: (path: null | string) => void
|
||||
reorderable?: boolean
|
||||
dragging?: boolean
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLElement>
|
||||
@ -618,6 +647,7 @@ interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {
|
||||
function SidebarWorkspaceGroup({
|
||||
group,
|
||||
renderRows,
|
||||
onNewSession,
|
||||
reorderable = false,
|
||||
dragging = false,
|
||||
dragHandleProps,
|
||||
@ -634,18 +664,31 @@ function SidebarWorkspaceGroup({
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
|
||||
<button
|
||||
className="group/workspace flex min-h-6 cursor-pointer items-center gap-1 px-2 pt-1 text-left text-[0.6875rem] font-medium text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)"
|
||||
onClick={() => setOpen(value => !value)}
|
||||
title={group.path ?? undefined}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{group.label}</span>
|
||||
<SidebarCount>{group.sessions.length}</SidebarCount>
|
||||
<DisclosureCaret
|
||||
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
|
||||
open={open}
|
||||
/>
|
||||
<div className="group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem] font-medium text-(--ui-text-tertiary)">
|
||||
<button
|
||||
className="flex min-w-0 cursor-pointer items-center gap-1 bg-transparent text-left hover:text-(--ui-text-secondary)"
|
||||
onClick={() => setOpen(value => !value)}
|
||||
title={group.path ?? undefined}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{group.label}</span>
|
||||
<SidebarCount>{group.sessions.length}</SidebarCount>
|
||||
<DisclosureCaret
|
||||
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
|
||||
open={open}
|
||||
/>
|
||||
</button>
|
||||
{onNewSession && (
|
||||
<button
|
||||
aria-label={`New session in ${group.label}`}
|
||||
className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
|
||||
onClick={() => onNewSession(group.path)}
|
||||
title={`New session in ${group.label}`}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="add" size="0.75rem" />
|
||||
</button>
|
||||
)}
|
||||
{reorderable && (
|
||||
<span
|
||||
{...dragHandleProps}
|
||||
@ -663,7 +706,7 @@ function SidebarWorkspaceGroup({
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<>
|
||||
{renderRows(visibleSessions)}
|
||||
@ -687,6 +730,7 @@ function SidebarWorkspaceGroup({
|
||||
interface SortableWorkspaceProps {
|
||||
group: SidebarSessionGroup
|
||||
renderRows: (sessions: SessionInfo[]) => React.ReactNode
|
||||
onNewSession?: (path: null | string) => void
|
||||
}
|
||||
|
||||
function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) {
|
||||
@ -702,6 +746,7 @@ interface SortableSessionRowProps {
|
||||
isPinned: boolean
|
||||
isSelected: boolean
|
||||
isWorking: boolean
|
||||
onArchive: () => void
|
||||
onDelete: () => void
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
|
||||
@ -26,6 +26,7 @@ interface SessionActions {
|
||||
title: string
|
||||
pinned?: boolean
|
||||
onPin?: () => void
|
||||
onArchive?: () => void
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
@ -40,7 +41,7 @@ interface ItemSpec {
|
||||
variant?: 'destructive'
|
||||
}
|
||||
|
||||
function useSessionActions({ sessionId, title, pinned = false, onPin, onDelete }: SessionActions) {
|
||||
function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive, onDelete }: SessionActions) {
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
|
||||
const items: ItemSpec[] = [
|
||||
@ -81,6 +82,15 @@ function useSessionActions({ sessionId, title, pinned = false, onPin, onDelete }
|
||||
setRenameOpen(true)
|
||||
}
|
||||
},
|
||||
{
|
||||
disabled: !onArchive,
|
||||
icon: 'archive',
|
||||
label: 'Archive',
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onArchive?.()
|
||||
}
|
||||
},
|
||||
{
|
||||
className: 'text-destructive focus:text-destructive',
|
||||
disabled: !onDelete,
|
||||
|
||||
@ -14,6 +14,7 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
||||
isPinned: boolean
|
||||
isSelected: boolean
|
||||
isWorking: boolean
|
||||
onArchive: () => void
|
||||
onDelete: () => void
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
@ -45,6 +46,7 @@ export function SidebarSessionRow({
|
||||
isPinned,
|
||||
isSelected,
|
||||
isWorking,
|
||||
onArchive,
|
||||
onDelete,
|
||||
onPin,
|
||||
onResume,
|
||||
@ -61,7 +63,14 @@ export function SidebarSessionRow({
|
||||
const handleLabel = `Reorder ${title}`
|
||||
|
||||
return (
|
||||
<SessionContextMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
|
||||
<SessionContextMenu
|
||||
onArchive={onArchive}
|
||||
onDelete={onDelete}
|
||||
onPin={onPin}
|
||||
pinned={isPinned}
|
||||
sessionId={session.id}
|
||||
title={title}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'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',
|
||||
@ -88,6 +97,15 @@ export function SidebarSessionRow({
|
||||
return
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
onArchive()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onResume()
|
||||
}}
|
||||
type="button"
|
||||
@ -127,7 +145,14 @@ export function SidebarSessionRow({
|
||||
{age}
|
||||
</span>
|
||||
)}
|
||||
<SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
|
||||
<SessionActionsMenu
|
||||
onArchive={onArchive}
|
||||
onDelete={onDelete}
|
||||
onPin={onPin}
|
||||
pinned={isPinned}
|
||||
sessionId={session.id}
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
aria-label={`Actions for ${title}`}
|
||||
className="size-5 rounded-md bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
|
||||
|
||||
@ -12,6 +12,7 @@ interface SessionRowCommonProps {
|
||||
isPinned: boolean
|
||||
isSelected: boolean
|
||||
isWorking: boolean
|
||||
onArchive: () => void
|
||||
onDelete: () => void
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
@ -20,6 +21,7 @@ interface SessionRowCommonProps {
|
||||
interface VirtualSessionListProps {
|
||||
activeSessionId: null | string
|
||||
className?: string
|
||||
onArchiveSession: (sessionId: string) => void
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
onResumeSession: (sessionId: string) => void
|
||||
onTogglePin: (sessionId: string) => void
|
||||
@ -35,6 +37,7 @@ const OVERSCAN_ROWS = 12
|
||||
export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
||||
activeSessionId,
|
||||
className,
|
||||
onArchiveSession,
|
||||
onDeleteSession,
|
||||
onResumeSession,
|
||||
onTogglePin,
|
||||
@ -72,6 +75,7 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
||||
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)
|
||||
|
||||
@ -113,7 +113,7 @@ interface SectionSearchEntry {
|
||||
}
|
||||
|
||||
const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
|
||||
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New agent', detail: 'Start a fresh session' },
|
||||
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New session', detail: 'Start a fresh session' },
|
||||
{ id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' },
|
||||
{ id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' },
|
||||
{
|
||||
|
||||
@ -6,6 +6,7 @@ import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 're
|
||||
import { BootFailureOverlay } from '@/components/boot-failure-overlay'
|
||||
import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
|
||||
import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay'
|
||||
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
|
||||
import { Pane, PaneMain } from '@/components/pane-shell'
|
||||
import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
|
||||
@ -33,6 +34,8 @@ import {
|
||||
$selectedStoredSessionId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setCurrentModel,
|
||||
setCurrentProvider,
|
||||
setMessages,
|
||||
@ -122,6 +125,7 @@ export function DesktopController() {
|
||||
settingsOpen,
|
||||
toggleCommandCenter
|
||||
} = useOverlayRouting()
|
||||
|
||||
const terminalTakeoverActive = chatOpen && terminalTakeover
|
||||
|
||||
const titlebarToolGroups = useGroupRegistry<TitlebarTool>()
|
||||
@ -192,7 +196,10 @@ export function DesktopController() {
|
||||
|
||||
try {
|
||||
const limit = $sessionsLimit.get()
|
||||
const result = await listSessions(limit)
|
||||
// Require at least one message so abandoned/empty "Untitled" drafts (one
|
||||
// was created per TUI/desktop launch before the lazy-create fix) don't
|
||||
// clutter the sidebar.
|
||||
const result = await listSessions(limit, 1)
|
||||
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
setSessions(result.sessions)
|
||||
@ -324,6 +331,7 @@ export function DesktopController() {
|
||||
})
|
||||
|
||||
const {
|
||||
archiveSession,
|
||||
branchCurrentSession,
|
||||
createBackendSessionForSend,
|
||||
openSettings,
|
||||
@ -392,6 +400,29 @@ export function DesktopController() {
|
||||
[branchCurrentSession, refreshSessions]
|
||||
)
|
||||
|
||||
const startSessionInWorkspace = useCallback(
|
||||
(path: null | string) => {
|
||||
startFreshSessionDraft()
|
||||
|
||||
const target = path?.trim()
|
||||
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
// The next message creates the backend session in $currentCwd, so seed
|
||||
// it (and the branch) from the workspace the user clicked the + on.
|
||||
setCurrentCwd(target)
|
||||
void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
|
||||
.then(info => {
|
||||
setCurrentCwd(info.cwd || target)
|
||||
setCurrentBranch(info.branch || '')
|
||||
})
|
||||
.catch(() => undefined)
|
||||
},
|
||||
[requestGateway, startFreshSessionDraft]
|
||||
)
|
||||
|
||||
const handleSkinCommand = useSkinCommand()
|
||||
|
||||
const { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } =
|
||||
@ -461,9 +492,11 @@ export function DesktopController() {
|
||||
const sidebar = (
|
||||
<ChatSidebar
|
||||
currentView={currentView}
|
||||
onArchiveSession={sessionId => void archiveSession(sessionId)}
|
||||
onDeleteSession={sessionId => void removeSession(sessionId)}
|
||||
onLoadMoreSessions={loadMoreSessions}
|
||||
onNavigate={selectSidebarItem}
|
||||
onNewSessionInWorkspace={startSessionInWorkspace}
|
||||
onResumeSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
)
|
||||
@ -485,6 +518,7 @@ export function DesktopController() {
|
||||
/>
|
||||
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
|
||||
<UpdatesOverlay />
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
|
||||
{settingsOpen && (
|
||||
@ -575,10 +609,10 @@ export function DesktopController() {
|
||||
titlebarTools={titlebarToolGroups.flat.right}
|
||||
>
|
||||
<Pane
|
||||
disabled={terminalTakeoverActive}
|
||||
id="chat-sidebar"
|
||||
maxWidth={SIDEBAR_MAX_WIDTH}
|
||||
minWidth={SIDEBAR_DEFAULT_WIDTH}
|
||||
disabled={terminalTakeoverActive}
|
||||
resizable
|
||||
side="left"
|
||||
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
|
||||
|
||||
@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import type { NavigateFunction } from 'react-router-dom'
|
||||
|
||||
import { deleteSession, getSessionMessages } from '@/hermes'
|
||||
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
|
||||
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
|
||||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
|
||||
@ -751,7 +751,39 @@ export function useSessionActions({
|
||||
]
|
||||
)
|
||||
|
||||
const archiveSession = useCallback(
|
||||
async (storedSessionId: string) => {
|
||||
clearNotifications()
|
||||
|
||||
const archived = $sessions.get().find(s => s.id === storedSessionId)
|
||||
const wasSelected = selectedStoredSessionId === storedSessionId
|
||||
const previousPinned = $pinnedSessionIds.get()
|
||||
|
||||
// Soft-hide: drop from the sidebar immediately, keep the data.
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
|
||||
|
||||
if (wasSelected) {
|
||||
startFreshSessionDraft(true)
|
||||
}
|
||||
|
||||
try {
|
||||
await setSessionArchived(storedSessionId, true)
|
||||
notify({ durationMs: 2_000, kind: 'success', message: 'Archived' })
|
||||
} catch (err) {
|
||||
if (archived) {
|
||||
setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
|
||||
}
|
||||
|
||||
$pinnedSessionIds.set(previousPinned)
|
||||
notifyError(err, 'Archive failed')
|
||||
}
|
||||
},
|
||||
[selectedStoredSessionId, startFreshSessionDraft]
|
||||
)
|
||||
|
||||
return {
|
||||
archiveSession,
|
||||
branchCurrentSession,
|
||||
closeSettings,
|
||||
createBackendSessionForSend,
|
||||
|
||||
@ -311,11 +311,15 @@ export const MODE_OPTIONS: ModeOption[] = [
|
||||
{ id: 'system', label: 'System', description: 'Follow OS appearance', icon: Monitor }
|
||||
]
|
||||
|
||||
export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools', string> = {
|
||||
export const SEARCH_PLACEHOLDER: Record<
|
||||
'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools',
|
||||
string
|
||||
> = {
|
||||
about: 'About Hermes Desktop',
|
||||
config: 'Search settings...',
|
||||
gateway: 'Gateway connection...',
|
||||
keys: 'Search API keys...',
|
||||
mcp: 'Search MCP servers...',
|
||||
sessions: 'Search archived sessions...',
|
||||
tools: 'Search skills and tools...'
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons'
|
||||
import { Archive, Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
@ -19,6 +19,7 @@ import { SEARCH_PLACEHOLDER, SECTIONS } from './constants'
|
||||
import { GatewaySettings } from './gateway-settings'
|
||||
import { KeysSettings } from './keys-settings'
|
||||
import { McpSettings } from './mcp-settings'
|
||||
import { SessionsSettings } from './sessions-settings'
|
||||
import { ToolsSettings } from './tools-settings'
|
||||
import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types'
|
||||
|
||||
@ -27,6 +28,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
'gateway',
|
||||
'keys',
|
||||
'mcp',
|
||||
'sessions',
|
||||
'tools',
|
||||
'about'
|
||||
]
|
||||
@ -40,6 +42,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
|
||||
gateway: '',
|
||||
keys: '',
|
||||
mcp: '',
|
||||
sessions: '',
|
||||
tools: ''
|
||||
})
|
||||
|
||||
@ -149,6 +152,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
|
||||
label="MCP"
|
||||
onClick={() => setActiveView('mcp')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={activeView === 'sessions'}
|
||||
icon={Archive}
|
||||
label="Archived Chats"
|
||||
onClick={() => setActiveView('sessions')}
|
||||
/>
|
||||
<div className="my-2 h-px bg-border/30" />
|
||||
<OverlayNavItem
|
||||
active={activeView === 'about'}
|
||||
@ -200,6 +209,8 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
|
||||
<KeysSettings query={queries.keys} />
|
||||
) : activeView === 'mcp' ? (
|
||||
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} query={queries.mcp} />
|
||||
) : activeView === 'sessions' ? (
|
||||
<SessionsSettings query={queries.sessions} />
|
||||
) : (
|
||||
<ToolsSettings query={queries.tools} />
|
||||
)}
|
||||
|
||||
168
apps/desktop/src/app/settings/sessions-settings.tsx
Normal file
168
apps/desktop/src/app/settings/sessions-settings.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, ArchiveOff, Loader2, Trash2 } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { setSessions } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'
|
||||
import type { SearchProps } from './types'
|
||||
|
||||
const ARCHIVED_FETCH_LIMIT = 200
|
||||
|
||||
function workspaceLabel(cwd: null | string | undefined): string {
|
||||
const path = cwd?.trim()
|
||||
|
||||
if (!path) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return (
|
||||
path
|
||||
.replace(/[/\\]+$/, '')
|
||||
.split(/[/\\]/)
|
||||
.filter(Boolean)
|
||||
.pop() ?? path
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionsSettings({ query }: SearchProps) {
|
||||
const [sessions, setLocalSessions] = useState<SessionInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
|
||||
setLocalSessions(result.sessions)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not load archived sessions')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const unarchive = useCallback(async (session: SessionInfo) => {
|
||||
setBusyId(session.id)
|
||||
|
||||
try {
|
||||
await setSessionArchived(session.id, false)
|
||||
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
|
||||
// Surface it again in the sidebar without waiting for a full refresh.
|
||||
setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)])
|
||||
triggerHaptic('selection')
|
||||
notify({ durationMs: 2_000, kind: 'success', message: 'Restored' })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Unarchive failed')
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const remove = useCallback(async (session: SessionInfo) => {
|
||||
if (!window.confirm(`Permanently delete "${sessionTitle(session)}"? This cannot be undone.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusyId(session.id)
|
||||
|
||||
try {
|
||||
await deleteSession(session.id)
|
||||
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
|
||||
triggerHaptic('warning')
|
||||
} catch (err) {
|
||||
notifyError(err, 'Delete failed')
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase()
|
||||
|
||||
if (!needle) {
|
||||
return sessions
|
||||
}
|
||||
|
||||
return sessions.filter(session =>
|
||||
[sessionTitle(session), session.preview ?? '', session.cwd ?? ''].join(' ').toLowerCase().includes(needle)
|
||||
)
|
||||
}, [query, sessions])
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState label="Loading archived sessions…" />
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<SectionHeading
|
||||
icon={Archive}
|
||||
meta={sessions.length ? String(sessions.length) : undefined}
|
||||
title="Archived sessions"
|
||||
/>
|
||||
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
Archived chats are hidden from the sidebar but keep all their messages. Ctrl/⌘-click a chat in the sidebar to
|
||||
archive it.
|
||||
</p>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
description={query.trim() ? 'No archived chats match your search.' : 'Archive a chat to hide it here.'}
|
||||
title="Nothing archived"
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-border/30">
|
||||
{filtered.map(session => {
|
||||
const label = workspaceLabel(session.cwd)
|
||||
const busy = busyId === session.id
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => void unarchive(session)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArchiveOff className="size-3.5" />}
|
||||
<span>Unarchive</span>
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Delete permanently"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(session)}
|
||||
size="icon"
|
||||
title="Delete permanently"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
description={session.preview || undefined}
|
||||
hint={label ? `${label} · ${session.message_count} messages` : `${session.message_count} messages`}
|
||||
key={session.id}
|
||||
title={sessionTitle(session)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
@ -4,8 +4,8 @@ import type { HermesGateway } from '@/hermes'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'tools' | `config:${string}`
|
||||
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools'
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools' | `config:${string}`
|
||||
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools'
|
||||
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
|
||||
|
||||
export interface SettingsPageProps {
|
||||
|
||||
@ -19,9 +19,9 @@ import {
|
||||
filePathFromMediaPath,
|
||||
mediaExternalUrl,
|
||||
mediaKind,
|
||||
mediaMime,
|
||||
mediaName,
|
||||
mediaPathFromMarkdownHref
|
||||
mediaPathFromMarkdownHref,
|
||||
mediaStreamUrl
|
||||
} from '@/lib/media'
|
||||
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
|
||||
import { cn } from '@/lib/utils'
|
||||
@ -40,24 +40,22 @@ import { cn } from '@/lib/utils'
|
||||
// LLM convention). The default false-setting only accepts `$$...$$`.
|
||||
const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
|
||||
|
||||
async function typedBlobUrl(dataUrl: string, mime: string): Promise<string> {
|
||||
const blob = await fetch(dataUrl).then(response => response.blob())
|
||||
|
||||
return URL.createObjectURL(new Blob([await blob.arrayBuffer()], { type: mime }))
|
||||
}
|
||||
|
||||
async function mediaSrc(path: string): Promise<string> {
|
||||
if (/^(?:https?|data):/i.test(path)) {
|
||||
return path
|
||||
}
|
||||
|
||||
// Stream audio/video through the custom protocol: data URLs are capped and
|
||||
// load the whole file into memory, which broke playback for larger videos.
|
||||
if (window.hermesDesktop && ['audio', 'video'].includes(mediaKind(path))) {
|
||||
return mediaStreamUrl(path)
|
||||
}
|
||||
|
||||
if (!window.hermesDesktop?.readFileDataUrl) {
|
||||
return mediaExternalUrl(path)
|
||||
}
|
||||
|
||||
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
|
||||
|
||||
return ['audio', 'video'].includes(mediaKind(path)) ? typedBlobUrl(dataUrl, mediaMime(path)) : dataUrl
|
||||
return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
|
||||
}
|
||||
|
||||
function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) {
|
||||
@ -278,10 +276,7 @@ const MarkdownTextImpl = () => {
|
||||
// render, which churns Streamdown's outer memo + propagates new prop
|
||||
// identities into every Block. The plugin set really only varies on
|
||||
// `isStreaming`, so memoize on that.
|
||||
const plugins = useMemo(
|
||||
() => (isStreaming ? { math: mathPlugin } : { math: mathPlugin, code }),
|
||||
[isStreaming]
|
||||
)
|
||||
const plugins = useMemo(() => (isStreaming ? { math: mathPlugin } : { math: mathPlugin, code }), [isStreaming])
|
||||
|
||||
const components = useMemo(
|
||||
() =>
|
||||
|
||||
183
apps/desktop/src/components/gateway-connecting-overlay.tsx
Normal file
183
apps/desktop/src/components/gateway-connecting-overlay.tsx
Normal file
@ -0,0 +1,183 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $desktopBoot } from '@/store/boot'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
|
||||
// Static, always-legible prefix; only TAIL ever scrambles. Splitting them at
|
||||
// the render level means no timer logic (even a stale HMR one) can ever
|
||||
// scramble "CONN".
|
||||
const PREFIX = 'CONN'
|
||||
const TAIL = 'ECTING'
|
||||
// Even-weight mono ascii so cycling glyphs don't jump width (matches the
|
||||
// nousnet-web download-button decode effect).
|
||||
const SCRAMBLE_CHARS = '/\\|-_=+<>~:*'
|
||||
const TICK_MS = 45
|
||||
|
||||
// Exit choreography (ms): text fades down + out, hold, then the overlay fades.
|
||||
const TEXT_OUT_MS = 360
|
||||
const POST_TEXT_HOLD_MS = 300
|
||||
const OVERLAY_OUT_MS = 520
|
||||
// Preview-only: how long to "connect" for, and the pause before replaying.
|
||||
const PREVIEW_CONNECT_MS = 2600
|
||||
const PREVIEW_REPLAY_MS = 1100
|
||||
|
||||
type Phase = 'live' | 'text-out' | 'overlay-out' | 'gone'
|
||||
|
||||
// Dev affordance: a warm Cmd+R reconnects almost instantly, so the overlay
|
||||
// only flashes. Load with `?connecting=1` to force a looping preview.
|
||||
function forcedPreview(): boolean {
|
||||
if (!import.meta.env.DEV || typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return new URLSearchParams(window.location.search).get('connecting') === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function scrambledTail(resolvedCount: number): string {
|
||||
return Array.from(TAIL, (ch, i) =>
|
||||
i < resolvedCount ? ch : SCRAMBLE_CHARS[(Math.random() * SCRAMBLE_CHARS.length) | 0]
|
||||
).join('')
|
||||
}
|
||||
|
||||
export function GatewayConnectingOverlay() {
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const boot = useStore($desktopBoot)
|
||||
const [previewing] = useState(forcedPreview)
|
||||
const [tail, setTail] = useState(TAIL)
|
||||
const [phase, setPhase] = useState<Phase>('live')
|
||||
|
||||
const connecting = gatewayState !== 'open' && !boot.error
|
||||
// Latches once we've actually shown the overlay, so the brief frame where
|
||||
// gatewayState flips to "open" (connecting -> false) before the exit phase
|
||||
// kicks in doesn't unmount us and cause a flash.
|
||||
const shownRef = useRef(false)
|
||||
|
||||
if (previewing || connecting) {
|
||||
shownRef.current = true
|
||||
}
|
||||
|
||||
// Decode loop — only while live (freeze the resolved word during the exit).
|
||||
useEffect(() => {
|
||||
if (phase !== 'live' || (!previewing && !connecting)) {
|
||||
return
|
||||
}
|
||||
|
||||
let resolved = 0
|
||||
let hold = 0
|
||||
|
||||
const id = window.setInterval(() => {
|
||||
if (resolved >= TAIL.length) {
|
||||
hold += 1
|
||||
|
||||
if (hold > 16) {
|
||||
resolved = 0
|
||||
hold = 0
|
||||
}
|
||||
|
||||
setTail(TAIL)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
resolved += 0.5
|
||||
setTail(scrambledTail(Math.floor(resolved)))
|
||||
}, TICK_MS)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [phase, previewing, connecting])
|
||||
|
||||
// Kick off the exit when connected: real connect, or a faked timer in preview.
|
||||
useEffect(() => {
|
||||
if (phase !== 'live') {
|
||||
return
|
||||
}
|
||||
|
||||
if (previewing) {
|
||||
const id = window.setTimeout(() => {
|
||||
setTail(TAIL)
|
||||
setPhase('text-out')
|
||||
}, PREVIEW_CONNECT_MS)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}
|
||||
|
||||
if (gatewayState === 'open' && shownRef.current) {
|
||||
setTail(TAIL)
|
||||
setPhase('text-out')
|
||||
}
|
||||
}, [phase, previewing, gatewayState])
|
||||
|
||||
// Advance the exit choreography: text-out -> overlay-out -> gone.
|
||||
useEffect(() => {
|
||||
if (phase === 'text-out') {
|
||||
const id = window.setTimeout(() => setPhase('overlay-out'), TEXT_OUT_MS + POST_TEXT_HOLD_MS)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}
|
||||
|
||||
if (phase === 'overlay-out') {
|
||||
const id = window.setTimeout(() => setPhase('gone'), OVERLAY_OUT_MS)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}
|
||||
|
||||
// Preview replays so we can keep watching the transition.
|
||||
if (phase === 'gone' && previewing) {
|
||||
const id = window.setTimeout(() => {
|
||||
setTail(TAIL)
|
||||
setPhase('live')
|
||||
}, PREVIEW_REPLAY_MS)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}
|
||||
}, [phase, previewing])
|
||||
|
||||
// Boot failed — BootFailureOverlay owns the screen; don't linger behind it.
|
||||
if (boot.error && !previewing) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Real connect: once the fade finishes, get out of the way for good.
|
||||
if (phase === 'gone' && !previewing) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Never showed (e.g. gateway already up on a warm reload) — stay out.
|
||||
if (!previewing && !connecting && !shownRef.current) {
|
||||
return null
|
||||
}
|
||||
|
||||
const leaving = phase !== 'live'
|
||||
const overlayHidden = phase === 'overlay-out' || phase === 'gone'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-[1200] grid place-items-center bg-(--ui-chat-surface-background) transition-opacity duration-500 ease-out',
|
||||
overlayHidden ? 'pointer-events-none opacity-0' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<style>{'@keyframes gco-cursor { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }'}</style>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center pl-[0.4em] font-mono text-[0.64rem] font-semibold uppercase tracking-[0.4em] tabular-nums text-(--theme-primary) transition duration-300 ease-out',
|
||||
leaving ? 'translate-y-2 opacity-0 saturate-0' : 'translate-y-0 opacity-100 saturate-100'
|
||||
)}
|
||||
>
|
||||
{PREFIX}
|
||||
{tail}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="dither ml-0.5 inline-block size-2 shrink-0 -translate-y-px rounded-[1px]"
|
||||
style={{ animation: 'gco-cursor 1s step-end infinite' }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -111,9 +111,13 @@ export class HermesGateway extends JsonRpcGatewayClient {
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSessions(limit = 40, minMessages = 0): Promise<PaginatedSessions> {
|
||||
export async function listSessions(
|
||||
limit = 40,
|
||||
minMessages = 0,
|
||||
archived: 'exclude' | 'include' | 'only' = 'exclude'
|
||||
): Promise<PaginatedSessions> {
|
||||
const result = await window.hermesDesktop.api<PaginatedSessions>({
|
||||
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}`
|
||||
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}`
|
||||
})
|
||||
|
||||
return {
|
||||
@ -123,6 +127,14 @@ export async function listSessions(limit = 40, minMessages = 0): Promise<Paginat
|
||||
}
|
||||
}
|
||||
|
||||
export function setSessionArchived(id: string, archived: boolean): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: { archived }
|
||||
})
|
||||
}
|
||||
|
||||
export function searchSessions(query: string): Promise<SessionSearchResponse> {
|
||||
return window.hermesDesktop.api<SessionSearchResponse>({
|
||||
path: `/api/sessions/search?q=${encodeURIComponent(query)}`
|
||||
|
||||
@ -2,6 +2,8 @@ import {
|
||||
IconActivity as Activity,
|
||||
IconAlertCircle as AlertCircle,
|
||||
IconAlertTriangle as AlertTriangle,
|
||||
IconArchive as Archive,
|
||||
IconArchiveOff as ArchiveOff,
|
||||
IconArrowUp as ArrowUp,
|
||||
IconArrowUpRight as ArrowUpRight,
|
||||
IconAt as AtSign,
|
||||
@ -98,6 +100,8 @@ export {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Archive,
|
||||
ArchiveOff,
|
||||
ArrowUp,
|
||||
ArrowUpRight,
|
||||
AtSign,
|
||||
|
||||
@ -58,6 +58,13 @@ export function mediaExternalUrl(path: string): string {
|
||||
return /^(?:https?|file):/i.test(path) ? path : `file://${path}`
|
||||
}
|
||||
|
||||
// Custom Electron scheme (registered in electron/main.cjs) that streams a local
|
||||
// file with Range support. Used for audio/video so playback bypasses the data
|
||||
// URL size cap and supports seeking. `path` may be a plain path or `file://…`.
|
||||
export function mediaStreamUrl(path: string): string {
|
||||
return `hermes-media://stream/${encodeURIComponent(filePathFromMediaPath(path))}`
|
||||
}
|
||||
|
||||
export function mediaPathFromMarkdownHref(href?: string): string | null {
|
||||
if (!href?.startsWith('#media:')) {
|
||||
return null
|
||||
|
||||
@ -240,6 +240,7 @@ export interface SessionCreateResponse {
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
archived?: boolean
|
||||
cwd?: null | string
|
||||
ended_at: null | number
|
||||
id: string
|
||||
|
||||
@ -1131,22 +1131,51 @@ async def get_action_status(name: str, lines: int = 200):
|
||||
|
||||
|
||||
@app.get("/api/sessions")
|
||||
async def get_sessions(limit: int = 20, offset: int = 0, min_messages: int = 0):
|
||||
async def get_sessions(
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
min_messages: int = 0,
|
||||
archived: str = "exclude",
|
||||
):
|
||||
"""List sessions.
|
||||
|
||||
``archived`` controls how soft-archived sessions are treated:
|
||||
``exclude`` (default) hides them, ``only`` returns just the archived ones
|
||||
(used by the desktop "Archived sessions" settings panel), and ``include``
|
||||
returns both.
|
||||
"""
|
||||
if archived not in ("exclude", "only", "include"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="archived must be one of: exclude, only, include",
|
||||
)
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
try:
|
||||
min_message_count = max(0, min_messages)
|
||||
archived_only = archived == "only"
|
||||
include_archived = archived == "include"
|
||||
sessions = db.list_sessions_rich(
|
||||
limit=limit, offset=offset, min_message_count=min_message_count
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
min_message_count=min_message_count,
|
||||
include_archived=include_archived,
|
||||
archived_only=archived_only,
|
||||
)
|
||||
total = db.session_count(
|
||||
min_message_count=min_message_count,
|
||||
include_archived=include_archived,
|
||||
archived_only=archived_only,
|
||||
)
|
||||
total = db.session_count(min_message_count=min_message_count)
|
||||
now = time.time()
|
||||
for s in sessions:
|
||||
s["is_active"] = (
|
||||
s.get("ended_at") is None
|
||||
and (now - s.get("last_active", s.get("started_at", 0))) < 300
|
||||
)
|
||||
# SQLite stores the flag as 0/1; expose a real JSON boolean.
|
||||
s["archived"] = bool(s.get("archived"))
|
||||
return {"sessions": sessions, "total": total, "limit": limit, "offset": offset}
|
||||
finally:
|
||||
db.close()
|
||||
@ -3707,25 +3736,39 @@ async def delete_session_endpoint(session_id: str):
|
||||
|
||||
class SessionRename(BaseModel):
|
||||
title: Optional[str] = None
|
||||
archived: Optional[bool] = None
|
||||
|
||||
|
||||
@app.patch("/api/sessions/{session_id}")
|
||||
async def rename_session_endpoint(session_id: str, body: SessionRename):
|
||||
"""Rename a session (or clear its title when ``title`` is empty/null)."""
|
||||
"""Update a session: rename (or clear its title) and/or archive it.
|
||||
|
||||
``title`` renames (empty/null clears the title); ``archived`` soft-hides or
|
||||
restores the session. Either field may be omitted.
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
try:
|
||||
sid = db.resolve_session_id(session_id)
|
||||
if not sid:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
try:
|
||||
updated = db.set_session_title(sid, body.title or "")
|
||||
except ValueError as e:
|
||||
# Title too long, invalid characters, or already in use.
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return {"ok": True, "title": db.get_session_title(sid) or ""}
|
||||
if body.title is None and body.archived is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Nothing to update; provide 'title' and/or 'archived'.",
|
||||
)
|
||||
if body.title is not None:
|
||||
try:
|
||||
db.set_session_title(sid, body.title or "")
|
||||
except ValueError as e:
|
||||
# Title too long, invalid characters, or already in use.
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
if body.archived is not None:
|
||||
db.set_session_archived(sid, body.archived)
|
||||
result = {"ok": True, "title": db.get_session_title(sid) or ""}
|
||||
if body.archived is not None:
|
||||
result["archived"] = bool(body.archived)
|
||||
return result
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@ -264,6 +264,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
handoff_platform TEXT,
|
||||
handoff_error TEXT,
|
||||
rewind_count INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
|
||||
);
|
||||
|
||||
@ -1430,6 +1431,22 @@ class SessionDB:
|
||||
row = cursor.fetchone()
|
||||
return row["title"] if row else None
|
||||
|
||||
def set_session_archived(self, session_id: str, archived: bool) -> bool:
|
||||
"""Archive or unarchive a session.
|
||||
|
||||
Archived sessions are hidden from the default session list but keep all
|
||||
their messages — this is a soft hide, not a delete. Returns True when a
|
||||
row was updated.
|
||||
"""
|
||||
def _do(conn):
|
||||
cursor = conn.execute(
|
||||
"UPDATE sessions SET archived = ? WHERE id = ?",
|
||||
(1 if archived else 0, session_id),
|
||||
)
|
||||
return cursor.rowcount
|
||||
rowcount = self._execute_write(_do)
|
||||
return rowcount > 0
|
||||
|
||||
def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:
|
||||
"""Look up a session by exact title. Returns session dict or None."""
|
||||
with self._lock:
|
||||
@ -1549,6 +1566,8 @@ class SessionDB:
|
||||
min_message_count: int = 0,
|
||||
project_compression_tips: bool = True,
|
||||
order_by_last_active: bool = False,
|
||||
include_archived: bool = False,
|
||||
archived_only: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List sessions with preview (first user message) and last active timestamp.
|
||||
|
||||
@ -1604,6 +1623,10 @@ class SessionDB:
|
||||
if min_message_count > 0:
|
||||
where_clauses.append("s.message_count >= ?")
|
||||
params.append(min_message_count)
|
||||
if archived_only:
|
||||
where_clauses.append("s.archived = 1")
|
||||
elif not include_archived:
|
||||
where_clauses.append("s.archived = 0")
|
||||
|
||||
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
||||
if order_by_last_active:
|
||||
@ -3027,7 +3050,13 @@ class SessionDB:
|
||||
# Utility
|
||||
# =========================================================================
|
||||
|
||||
def session_count(self, source: str = None, min_message_count: int = 0) -> int:
|
||||
def session_count(
|
||||
self,
|
||||
source: str = None,
|
||||
min_message_count: int = 0,
|
||||
include_archived: bool = False,
|
||||
archived_only: bool = False,
|
||||
) -> int:
|
||||
"""Count sessions, optionally filtered by source."""
|
||||
where_clauses = []
|
||||
params = []
|
||||
@ -3038,6 +3067,10 @@ class SessionDB:
|
||||
if min_message_count > 0:
|
||||
where_clauses.append("message_count >= ?")
|
||||
params.append(min_message_count)
|
||||
if archived_only:
|
||||
where_clauses.append("archived = 1")
|
||||
elif not include_archived:
|
||||
where_clauses.append("archived = 0")
|
||||
|
||||
where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
||||
|
||||
|
||||
@ -187,11 +187,11 @@ class TestWebServerEndpoints:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def list_sessions_rich(self, limit, offset, min_message_count=0):
|
||||
def list_sessions_rich(self, limit, offset, min_message_count=0, **kwargs):
|
||||
captured["list"] = min_message_count
|
||||
return []
|
||||
|
||||
def session_count(self, min_message_count=0):
|
||||
def session_count(self, min_message_count=0, **kwargs):
|
||||
captured["count"] = min_message_count
|
||||
return 0
|
||||
|
||||
@ -250,6 +250,76 @@ class TestWebServerEndpoints:
|
||||
resp = self.client.patch("/api/sessions/does-not-exist", json={"title": "x"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_archive_session_via_patch(self):
|
||||
"""PATCH archived=true soft-hides a session; archived=false restores it."""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
db.create_session(session_id="arch-me", source="cli")
|
||||
db.append_message(session_id="arch-me", role="user", content="hi")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
resp = self.client.patch("/api/sessions/arch-me", json={"archived": True})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["archived"] is True
|
||||
|
||||
# Hidden from the default list, surfaced by archived=only.
|
||||
listed = self.client.get("/api/sessions").json()
|
||||
assert all(s["id"] != "arch-me" for s in listed["sessions"])
|
||||
only = self.client.get("/api/sessions?archived=only").json()
|
||||
assert any(s["id"] == "arch-me" for s in only["sessions"])
|
||||
|
||||
resp = self.client.patch("/api/sessions/arch-me", json={"archived": False})
|
||||
assert resp.status_code == 200
|
||||
restored = self.client.get("/api/sessions").json()
|
||||
assert any(s["id"] == "arch-me" for s in restored["sessions"])
|
||||
|
||||
def test_patch_session_without_fields_is_400(self):
|
||||
"""An existing session + empty body is a bad request, not a 404."""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
db.create_session(session_id="no-fields", source="cli")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
resp = self.client.patch("/api/sessions/no-fields", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_get_sessions_rejects_unknown_archived_value(self):
|
||||
resp = self.client.get("/api/sessions?archived=bogus")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_get_sessions_archived_is_boolean(self):
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
db.create_session(session_id="bool-arch", source="cli")
|
||||
db.append_message(session_id="bool-arch", role="user", content="hi")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
row = next(s for s in self.client.get("/api/sessions").json()["sessions"] if s["id"] == "bool-arch")
|
||||
assert row["archived"] is False
|
||||
|
||||
def test_rename_response_omits_archived_when_not_set(self):
|
||||
"""Title-only PATCH keeps its legacy {ok, title} response shape."""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
db.create_session(session_id="title-only", source="cli")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
resp = self.client.patch("/api/sessions/title-only", json={"title": "Hi"})
|
||||
assert resp.status_code == 200
|
||||
assert "archived" not in resp.json()
|
||||
|
||||
def test_audio_transcription_endpoint(self, monkeypatch):
|
||||
import tools.transcription_tools as transcription_tools
|
||||
|
||||
|
||||
@ -3509,3 +3509,43 @@ class TestApplyWalProbe:
|
||||
assert any("journal_mode=WAL" in sql for sql in conn.executed), (
|
||||
"set-pragma must fire when probe returns 'delete'"
|
||||
)
|
||||
|
||||
|
||||
class TestSessionArchive:
|
||||
"""Soft-archiving hides a session from default listings without deleting it."""
|
||||
|
||||
def _seed(self, db, sid, *, archived=False):
|
||||
db.create_session(session_id=sid, source="cli")
|
||||
db.append_message(session_id=sid, role="user", content=f"hello from {sid}")
|
||||
if archived:
|
||||
db.set_session_archived(sid, True)
|
||||
|
||||
def test_set_session_archived_roundtrip(self, db):
|
||||
self._seed(db, "s1")
|
||||
assert db.set_session_archived("s1", True) is True
|
||||
assert db.get_session("s1")["archived"] == 1
|
||||
assert db.set_session_archived("s1", False) is True
|
||||
assert db.get_session("s1")["archived"] == 0
|
||||
|
||||
def test_set_session_archived_missing_row(self, db):
|
||||
assert db.set_session_archived("nope", True) is False
|
||||
|
||||
def test_archived_excluded_by_default(self, db):
|
||||
self._seed(db, "live")
|
||||
self._seed(db, "hidden", archived=True)
|
||||
|
||||
ids = [s["id"] for s in db.list_sessions_rich()]
|
||||
assert ids == ["live"]
|
||||
assert db.session_count() == 1
|
||||
|
||||
def test_archived_only_and_include(self, db):
|
||||
self._seed(db, "live")
|
||||
self._seed(db, "hidden", archived=True)
|
||||
|
||||
only = [s["id"] for s in db.list_sessions_rich(archived_only=True)]
|
||||
assert only == ["hidden"]
|
||||
assert db.session_count(archived_only=True) == 1
|
||||
|
||||
both = {s["id"] for s in db.list_sessions_rich(include_archived=True)}
|
||||
assert both == {"live", "hidden"}
|
||||
assert db.session_count(include_archived=True) == 2
|
||||
|
||||
@ -884,6 +884,73 @@ def test_session_title_queues_when_db_row_not_ready(monkeypatch):
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_notification_event_routing_by_session_key(monkeypatch):
|
||||
"""Background-process events surface only in the session that owns them."""
|
||||
mine = _session(session_key="mine")
|
||||
other = _session(session_key="other")
|
||||
monkeypatch.setattr(server, "_sessions", {"a": mine, "b": other})
|
||||
|
||||
# My own event → handle it.
|
||||
assert server._notification_event_belongs_elsewhere(mine, {"session_key": "mine"}) is False
|
||||
# Global/system event with no owner → handle it.
|
||||
assert server._notification_event_belongs_elsewhere(mine, {"session_key": ""}) is False
|
||||
assert server._notification_event_belongs_elsewhere(mine, {}) is False
|
||||
# Owned by another *live* session → defer to that session's poller.
|
||||
assert server._notification_event_belongs_elsewhere(mine, {"session_key": "other"}) is True
|
||||
# Owner is gone (not in _sessions) → handle as fallback so it isn't lost.
|
||||
assert server._notification_event_belongs_elsewhere(mine, {"session_key": "ghost"}) is False
|
||||
|
||||
|
||||
def test_session_create_does_not_persist_empty_row(monkeypatch):
|
||||
"""session.create must NOT eagerly write a DB row.
|
||||
|
||||
Every TUI/desktop launch opens a session here just to paint the composer;
|
||||
eagerly creating a row left an empty "Untitled" session behind for every
|
||||
launch the user never typed into. The row is created lazily on first prompt.
|
||||
"""
|
||||
created = []
|
||||
|
||||
class _FakeDB:
|
||||
def create_session(self, *args, **kwargs):
|
||||
created.append((args, kwargs))
|
||||
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
||||
monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None)
|
||||
monkeypatch.setattr(
|
||||
server.threading,
|
||||
"Timer",
|
||||
lambda *a, **k: types.SimpleNamespace(daemon=False, start=lambda: None),
|
||||
)
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.create", "params": {"cols": 80}}
|
||||
)
|
||||
sid = resp["result"]["session_id"]
|
||||
try:
|
||||
assert resp["result"]["stored_session_id"]
|
||||
assert created == [], "session.create should not persist an empty DB row"
|
||||
finally:
|
||||
server._sessions.pop(sid, None)
|
||||
|
||||
|
||||
def test_ensure_session_db_row_persists_with_cwd(monkeypatch, tmp_path):
|
||||
"""First prompt persists the row (INSERT OR IGNORE) capturing cwd up front."""
|
||||
created = []
|
||||
|
||||
class _FakeDB:
|
||||
def create_session(self, key, source=None, model=None, cwd=None):
|
||||
created.append({"key": key, "source": source, "model": model, "cwd": cwd})
|
||||
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
||||
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
|
||||
|
||||
server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path)})
|
||||
|
||||
assert created == [
|
||||
{"key": "k1", "source": "tui", "model": "test-model", "cwd": str(tmp_path)}
|
||||
]
|
||||
|
||||
|
||||
def test_session_title_clears_pending_after_persist(monkeypatch):
|
||||
class _FakeDB:
|
||||
def __init__(self):
|
||||
|
||||
@ -880,6 +880,7 @@ class ProcessRegistry:
|
||||
self.completion_queue.put({
|
||||
"type": "completion",
|
||||
"session_id": session.id,
|
||||
"session_key": session.session_key,
|
||||
"command": session.command,
|
||||
"exit_code": session.exit_code,
|
||||
"output": output_tail,
|
||||
|
||||
@ -702,6 +702,32 @@ def _register_session_cwd(session: dict | None) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _ensure_session_db_row(session: dict) -> None:
|
||||
"""Idempotently persist the session's DB row on first real activity.
|
||||
|
||||
Called from prompt.submit so a row only exists once the user actually sends
|
||||
a message — abandoned drafts never leave an empty "Untitled" session behind.
|
||||
Uses INSERT OR IGNORE under the hood, so re-calls (and the AIAgent's own
|
||||
lazy create) are no-ops. Captures cwd up front so workspace grouping works
|
||||
without waiting for a separate cwd update.
|
||||
"""
|
||||
key = session.get("session_key")
|
||||
if not key:
|
||||
return
|
||||
db = _get_db()
|
||||
if db is None:
|
||||
return
|
||||
try:
|
||||
db.create_session(
|
||||
key,
|
||||
source="tui",
|
||||
model=_resolve_model(),
|
||||
cwd=_session_cwd(session),
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("failed to persist desktop session row", exc_info=True)
|
||||
|
||||
|
||||
def _set_session_cwd(session: dict, cwd: str) -> str:
|
||||
resolved = os.path.abspath(os.path.expanduser(str(cwd)))
|
||||
if not os.path.isdir(resolved):
|
||||
@ -2750,17 +2776,12 @@ def _(rid, params: dict) -> dict:
|
||||
"transport": current_transport() or _stdio_transport,
|
||||
}
|
||||
_register_session_cwd(_sessions[sid])
|
||||
db = _get_db()
|
||||
if db is not None:
|
||||
try:
|
||||
db.create_session(
|
||||
key,
|
||||
source="tui",
|
||||
model=_resolve_model(),
|
||||
cwd=_sessions[sid]["cwd"],
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("failed to pre-create desktop session row", exc_info=True)
|
||||
# NOTE: we intentionally do NOT persist a DB row here. Every TUI/desktop
|
||||
# launch (and every "New agent" / draft) opens a session here just to paint
|
||||
# the composer, so eagerly creating a row left an "Untitled" empty session
|
||||
# behind for every launch the user never typed into. The row is now created
|
||||
# lazily on the first prompt (see _ensure_session_db_row + prompt.submit),
|
||||
# and the AIAgent's own INSERT-OR-IGNORE persists it on the first turn too.
|
||||
|
||||
# Return the lightweight session immediately so Ink can paint the composer
|
||||
# + skeleton panel, then build the real AIAgent just after this response is
|
||||
@ -3841,6 +3862,8 @@ def _(rid, params: dict) -> dict:
|
||||
session["last_active"] = time.time()
|
||||
_start_inflight_turn(session, text)
|
||||
|
||||
# Persist the DB row lazily, now that the user has actually sent a message.
|
||||
_ensure_session_db_row(session)
|
||||
_start_agent_build(sid, session)
|
||||
|
||||
def run_after_agent_ready() -> None:
|
||||
@ -3865,6 +3888,35 @@ def _(rid, params: dict) -> dict:
|
||||
return _ok(rid, {"status": "streaming"})
|
||||
|
||||
|
||||
def _notification_event_belongs_elsewhere(session: dict, evt: dict) -> bool:
|
||||
"""True if ``evt`` is owned by a *different* live session.
|
||||
|
||||
Background-process events carry the ``session_key`` of the session that
|
||||
started the process. Since all desktop sessions share one process-wide
|
||||
completion queue, each poller must skip events it doesn't own so a
|
||||
background job's completion surfaces in the session that launched it — not
|
||||
whichever poller happened to dequeue first. Orphaned events (owner gone)
|
||||
and global/system events (empty ``session_key``) return False so the
|
||||
current poller still handles them rather than losing them.
|
||||
"""
|
||||
evt_key = str(evt.get("session_key") or "")
|
||||
if not evt_key:
|
||||
return False
|
||||
if evt_key == str(session.get("session_key") or ""):
|
||||
return False
|
||||
try:
|
||||
snapshot = list(_sessions.values())
|
||||
except Exception:
|
||||
# If we can't safely enumerate live sessions, fail open so we don't
|
||||
# crash the poller thread or drop the event.
|
||||
return False
|
||||
|
||||
return any(
|
||||
s is not session and str(s.get("session_key") or "") == evt_key
|
||||
for s in snapshot
|
||||
)
|
||||
|
||||
|
||||
def _notification_poller_loop(
|
||||
stop_event: threading.Event, sid: str, session: dict
|
||||
) -> None:
|
||||
@ -3887,6 +3939,16 @@ def _notification_poller_loop(
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Multiple desktop sessions share this one process-wide queue. Only
|
||||
# consume events that belong to *this* session — otherwise a background
|
||||
# process started in session A would surface its completion in whichever
|
||||
# session's poller happened to wake first (Ben's "reported in a
|
||||
# different session" bug). Leave foreign events for their owner.
|
||||
if _notification_event_belongs_elsewhere(session, evt):
|
||||
process_registry.completion_queue.put(evt)
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
|
||||
_evt_sid = evt.get("session_id", "")
|
||||
if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid):
|
||||
continue
|
||||
@ -3917,12 +3979,17 @@ def _notification_poller_loop(
|
||||
session["running"] = False
|
||||
|
||||
# Drain any remaining events after stop signal (process all pending
|
||||
# before exiting so nothing is lost on shutdown).
|
||||
# before exiting so nothing is lost on shutdown). Events owned by other
|
||||
# live sessions are set aside and re-queued so their poller still sees them.
|
||||
deferred: list = []
|
||||
while not process_registry.completion_queue.empty():
|
||||
try:
|
||||
evt = process_registry.completion_queue.get_nowait()
|
||||
except Exception:
|
||||
break
|
||||
if _notification_event_belongs_elsewhere(session, evt):
|
||||
deferred.append(evt)
|
||||
continue
|
||||
_evt_sid = evt.get("session_id", "")
|
||||
if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid):
|
||||
continue
|
||||
@ -3951,6 +4018,10 @@ def _notification_poller_loop(
|
||||
with session["history_lock"]:
|
||||
session["running"] = False
|
||||
|
||||
# Hand any other sessions' events back to the shared queue.
|
||||
for evt in deferred:
|
||||
process_registry.completion_queue.put(evt)
|
||||
|
||||
|
||||
def _start_notification_poller(sid: str, session: dict) -> threading.Event:
|
||||
"""Start the background notification poller for a TUI session."""
|
||||
|
||||
Reference in New Issue
Block a user