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:
co-authored by
Copilot Autofix powered by AI
parent
ddc22866a3
commit
85b65e29f0
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user