feat: more ui qa
This commit is contained in:
@@ -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, [])
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 ?? []
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user