feat: more ui qa

This commit is contained in:
Brooklyn Nicholson
2026-05-16 21:26:50 -05:00
parent 64ab17182a
commit c7e6a48bfb
84 changed files with 939 additions and 1120 deletions
+48 -16
View File
@@ -14,7 +14,10 @@ type QueueState = Record<string, QueuedPromptEntry[]>
const STORAGE_KEY = 'hermes.desktop.composerQueue.v1'
const load = (): QueueState => {
if (typeof window === 'undefined') return {}
if (typeof window === 'undefined') {
return {}
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
const parsed = raw ? JSON.parse(raw) : null
@@ -26,10 +29,16 @@ const load = (): QueueState => {
}
const save = (state: QueueState) => {
if (typeof window === 'undefined') return
if (typeof window === 'undefined') {
return
}
try {
if (Object.keys(state).length === 0) window.localStorage.removeItem(STORAGE_KEY)
else window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
if (Object.keys(state).length === 0) {
window.localStorage.removeItem(STORAGE_KEY)
} else {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}
} catch {
// best-effort: storage may be unavailable, queue still works in-memory
}
@@ -41,8 +50,11 @@ const writeSession = (sid: string, queue: QueuedPromptEntry[]) => {
const current = $queuedPromptsBySession.get()
const next = { ...current }
if (queue.length === 0) delete next[sid]
else next[sid] = queue
if (queue.length === 0) {
delete next[sid]
} else {
next[sid] = queue
}
$queuedPromptsBySession.set(next)
save(next)
@@ -72,7 +84,9 @@ export const enqueueQueuedPrompt = (
): null | QueuedPromptEntry => {
const sid = sidOf(key)
if (!sid) return null
if (!sid) {
return null
}
const entry: QueuedPromptEntry = {
id: nextId(),
@@ -89,11 +103,15 @@ export const enqueueQueuedPrompt = (
export const dequeueQueuedPrompt = (key: string | null | undefined): null | QueuedPromptEntry => {
const sid = sidOf(key)
if (!sid) return null
if (!sid) {
return null
}
const [head, ...rest] = queueFor(sid)
if (!head) return null
if (!head) {
return null
}
writeSession(sid, rest)
@@ -103,12 +121,16 @@ export const dequeueQueuedPrompt = (key: string | null | undefined): null | Queu
export const removeQueuedPrompt = (key: string | null | undefined, id: string): boolean => {
const sid = sidOf(key)
if (!sid) return false
if (!sid) {
return false
}
const queue = queueFor(sid)
const next = queue.filter(e => e.id !== id)
if (next.length === queue.length) return false
if (next.length === queue.length) {
return false
}
writeSession(sid, next)
@@ -122,24 +144,32 @@ export const updateQueuedPrompt = (
): boolean => {
const sid = sidOf(key)
if (!sid) return false
if (!sid) {
return false
}
const queue = queueFor(sid)
let changed = false
const next = queue.map(entry => {
if (entry.id !== id) return entry
if (entry.id !== id) {
return entry
}
const attachments = update.attachments ? cloneAttachments(update.attachments) : entry.attachments
if (entry.text === update.text && !update.attachments) return entry
if (entry.text === update.text && !update.attachments) {
return entry
}
changed = true
return { ...entry, text: update.text, attachments }
})
if (!changed) return false
if (!changed) {
return false
}
writeSession(sid, next)
@@ -152,7 +182,9 @@ export const updateQueuedPromptText = (key: string | null | undefined, id: strin
export const clearQueuedPrompts = (key: string | null | undefined) => {
const sid = sidOf(key)
if (!sid || !(sid in $queuedPromptsBySession.get())) return
if (!sid || !(sid in $queuedPromptsBySession.get())) {
return
}
writeSession(sid, [])
}
+8 -1
View File
@@ -1,6 +1,13 @@
import { atom, computed, type ReadableAtom } from 'nanostores'
import { arraysEqual, insertUniqueId, persistBoolean, persistStringArray, storedBoolean, storedStringArray } from '@/lib/storage'
import {
arraysEqual,
insertUniqueId,
persistBoolean,
persistStringArray,
storedBoolean,
storedStringArray
} from '@/lib/storage'
import { $paneStates, ensurePaneRegistered, setPaneOpen, setPaneWidthOverride, togglePane } from './panes'
+3 -1
View File
@@ -164,7 +164,9 @@ async function fetchProviderDefaultModel(
// returned (model.options orders by recency / authenticated state, so
// the just-authenticated provider is usually first anyway).
const lower = preferredSlugs.map(s => s.toLowerCase())
const matched = providers.find((p: ModelOptionProvider) => lower.includes(String(p.slug).toLowerCase())) ?? providers[0]
const matched =
providers.find((p: ModelOptionProvider) => lower.includes(String(p.slug).toLowerCase())) ?? providers[0]
const models = matched.models ?? []
+9
View File
@@ -400,6 +400,15 @@ export function closeRightRailTab(tabId: RightRailTabId) {
export const closeActiveRightRailTab = () => closeRightRailTab($rightRailActiveTabId.get())
/** Dismisses the active preview + every file tab so the rail pane unmounts. */
export function closeRightRail() {
if ($previewTarget.get()) {
dismissPreviewTarget()
}
$filePreviewTabs.set([])
}
export function clearSessionPreviewRegistry() {
$sessionPreviewRegistry.set({})
setPreviewTarget(null)
+7 -1
View File
@@ -54,7 +54,13 @@ describe('subagent store', () => {
)
upsertSubagent(
's1',
{ status: 'running', subagent_id: 'a1', task_index: 0, tool_name: 'search_files', tool_preview: 'pattern=hermes' },
{
status: 'running',
subagent_id: 'a1',
task_index: 0,
tool_name: 'search_files',
tool_preview: 'pattern=hermes'
},
false,
'subagent.tool'
)
+62 -16
View File
@@ -56,15 +56,24 @@ const asStatus = (v: unknown): SubagentStatus =>
const compact = (text: string, max = PREVIEW_MAX) => {
const line = text.replace(/\s+/g, ' ').trim()
if (!line) return ''
if (!line) {
return ''
}
return line.length > max ? `${line.slice(0, max - 1)}` : line
}
const toolLabel = (name: string) =>
name.split('_').filter(Boolean).map(p => p[0]!.toUpperCase() + p.slice(1)).join(' ') || name
name
.split('_')
.filter(Boolean)
.map(p => p[0]!.toUpperCase() + p.slice(1))
.join(' ') || name
const formatTool = (name: string, preview = '') => {
const snippet = compact(preview, TOOL_PREVIEW_MAX)
return snippet ? `${toolLabel(name)}("${snippet}")` : toolLabel(name)
}
@@ -90,7 +99,10 @@ const idOf = (p: SubagentPayload) =>
const appendStream = (stream: SubagentStreamEntry[], entry: SubagentStreamEntry) => {
const last = stream.at(-1)
if (last?.kind === entry.kind && last.text === entry.text && last.isError === entry.isError) return stream
if (last?.kind === entry.kind && last.text === entry.text && last.isError === entry.isError) {
return stream
}
return [...stream, entry].slice(-MAX_STREAM)
}
@@ -108,19 +120,29 @@ function streamFromPayload(
for (const tail of asTail(payload.output_tail)) {
const line = tail.tool ? formatTool(tail.tool, tail.preview ?? '') : compact(tail.preview ?? '')
if (line) out.push({ at, isError: tail.isError, kind: tail.tool ? 'tool' : 'progress', text: line })
if (line) {
out.push({ at, isError: tail.isError, kind: tail.tool ? 'tool' : 'progress', text: line })
}
}
if (tool) out.push({ at, isError: !!payload.error, kind: 'tool', text: formatTool(tool, preview) })
if (tool) {
out.push({ at, isError: !!payload.error, kind: 'tool', text: formatTool(tool, preview) })
}
if (eventType === 'subagent.progress' && text)
if (eventType === 'subagent.progress' && text) {
out.push({ at, isError: !!payload.error, kind: 'progress', text })
}
if (eventType === 'subagent.thinking' && text) out.push({ at, kind: 'thinking', text })
if (eventType === 'subagent.thinking' && text) {
out.push({ at, kind: 'thinking', text })
}
const summary = compact(str(payload.summary) || str(payload.text))
if (TERMINAL.has(status) && summary)
if (TERMINAL.has(status) && summary) {
out.push({ at, isError: status === 'failed', kind: 'summary', text: summary })
}
return out
}
@@ -158,7 +180,10 @@ function toProgress(payload: SubagentPayload, prev: SubagentProgress | undefined
export function clearSessionSubagents(sid: string) {
const map = $subagentsBySession.get()
if (!(sid in map)) return
if (!(sid in map)) {
return
}
const { [sid]: _drop, ...rest } = map
$subagentsBySession.set(rest)
@@ -167,10 +192,16 @@ export function clearSessionSubagents(sid: string) {
export function pruneDelegateFallbackSubagents(sid: string) {
const map = $subagentsBySession.get()
const list = map[sid]
if (!list?.length) return
if (!list?.length) {
return
}
const next = list.filter(item => !item.id.startsWith('delegate-tool:'))
if (next.length === list.length) return
if (next.length === list.length) {
return
}
$subagentsBySession.set({ ...map, [sid]: next })
}
@@ -180,10 +211,16 @@ export function upsertSubagent(sid: string, payload: SubagentPayload, createIfMi
const list = map[sid] ?? []
const id = idOf(payload)
const idx = list.findIndex(item => item.id === id)
if (idx < 0 && !createIfMissing) return
if (idx < 0 && !createIfMissing) {
return
}
const prev = idx >= 0 ? list[idx] : undefined
if (prev && TERMINAL.has(prev.status)) return
if (prev && TERMINAL.has(prev.status)) {
return
}
const next = toProgress(payload, prev, eventType)
const nextList = idx >= 0 ? list.map(item => (item.id === id ? next : item)) : [...list, next]
@@ -193,17 +230,26 @@ export function upsertSubagent(sid: string, payload: SubagentPayload, createIfMi
export function buildSubagentTree(items: readonly SubagentProgress[]): SubagentNode[] {
const nodes = new Map<string, SubagentNode>()
for (const item of items) nodes.set(item.id, { ...item, children: [] })
for (const item of items) {
nodes.set(item.id, { ...item, children: [] })
}
const roots: SubagentNode[] = []
for (const node of nodes.values()) {
const parent = node.parentId ? nodes.get(node.parentId) : null
if (parent) parent.children.push(node)
else roots.push(node)
if (parent) {
parent.children.push(node)
} else {
roots.push(node)
}
}
const sort = (a: SubagentNode, b: SubagentNode) =>
a.startedAt - b.startedAt || a.taskIndex - b.taskIndex || a.goal.localeCompare(b.goal)
const walk = (node: SubagentNode) => node.children.sort(sort).forEach(walk)
roots.sort(sort).forEach(walk)