From 5b683f632b2913236a682a6ccf721fb4263a2eed Mon Sep 17 00:00:00 2001 From: qbit-mirror-bot Date: Thu, 2 Jul 2026 13:25:44 +0000 Subject: [PATCH] feat(desktop): add live tasks sidebar view --- apps/desktop/src/app/chat/sidebar/index.tsx | 9 +- apps/desktop/src/app/desktop-controller.tsx | 9 + apps/desktop/src/app/routes.ts | 4 + .../session/hooks/use-session-state-cache.ts | 6 + apps/desktop/src/app/tasks/index.tsx | 467 ++++++++++++++++++ apps/desktop/src/app/types.ts | 2 +- apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/store/session.ts | 39 ++ 8 files changed, 535 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/app/tasks/index.tsx diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 89e719f77600..40cd432f29de 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -95,7 +95,7 @@ import { setCurrentCwd } from '@/store/session' -import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes' +import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE, TASKS_ROUTE } from '../../routes' import type { SidebarNavItem } from '../../types' import { countLabel } from './chrome' @@ -143,6 +143,12 @@ const SIDEBAR_NAV: SidebarNavItem[] = [ icon: props => , route: SKILLS_ROUTE }, + { + id: 'tasks', + label: '', + icon: props => , + route: TASKS_ROUTE + }, { id: 'messaging', label: '', icon: props => , route: MESSAGING_ROUTE }, { id: 'artifacts', label: '', icon: props => , route: ARTIFACTS_ROUTE } ] @@ -1052,6 +1058,7 @@ export function ChatSidebar({ const active = (item.id === 'skills' && currentView === 'skills') || + (item.id === 'tasks' && currentView === 'tasks') || (item.id === 'messaging' && currentView === 'messaging') || (item.id === 'artifacts' && currentView === 'artifacts') diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 29f34b958909..280a0cd5be77 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -139,6 +139,7 @@ const MessagingView = lazy(async () => ({ default: (await import('./messaging')) const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).ProfilesView })) const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView })) const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView })) +const TasksView = lazy(async () => ({ default: (await import('./tasks')).TasksView })) // Latest cron-job sessions surfaced in the collapsed "Cron jobs" section. The // Cron sessions are written by a background scheduler tick (the desktop @@ -1194,6 +1195,14 @@ export function DesktopController() { } path="skills" /> + + + + } + path="tasks" + /> diff --git a/apps/desktop/src/app/routes.ts b/apps/desktop/src/app/routes.ts index 66ab264e474b..af26c678a997 100644 --- a/apps/desktop/src/app/routes.ts +++ b/apps/desktop/src/app/routes.ts @@ -3,6 +3,7 @@ export const NEW_CHAT_ROUTE = '/' export const SETTINGS_ROUTE = '/settings' export const COMMAND_CENTER_ROUTE = '/command-center' export const SKILLS_ROUTE = '/skills' +export const TASKS_ROUTE = '/tasks' export const MESSAGING_ROUTE = '/messaging' export const ARTIFACTS_ROUTE = '/artifacts' export const CRON_ROUTE = '/cron' @@ -20,6 +21,7 @@ export type AppView = | 'profiles' | 'settings' | 'skills' + | 'tasks' | 'starmap' export type AppRouteId = @@ -32,6 +34,7 @@ export type AppRouteId = | 'profiles' | 'settings' | 'skills' + | 'tasks' | 'starmap' export interface AppRoute { @@ -45,6 +48,7 @@ export const APP_ROUTES = [ { id: 'settings', path: SETTINGS_ROUTE, view: 'settings' }, { id: 'command-center', path: COMMAND_CENTER_ROUTE, view: 'command-center' }, { id: 'skills', path: SKILLS_ROUTE, view: 'skills' }, + { id: 'tasks', path: TASKS_ROUTE, view: 'tasks' }, { id: 'messaging', path: MESSAGING_ROUTE, view: 'messaging' }, { id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' }, { id: 'cron', path: CRON_ROUTE, view: 'cron' }, diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts index 3f8e02c8ca8a..a282a8d83355 100644 --- a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts +++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts @@ -8,6 +8,7 @@ import { setMutableRef } from '@/lib/mutable-ref' import { $busy, $messages, + clearRuntimeSessionMapping, noteSessionActivity, onSessionWatchdogClear, setCurrentFastMode, @@ -16,6 +17,7 @@ import { setCurrentProvider, setCurrentReasoningEffort, setCurrentServiceTier, + setRuntimeSessionMapping, setSessionAttention, setSessionWorking, setTurnStartedAt, @@ -106,6 +108,7 @@ export function useSessionStateCache({ if (storedSessionId) { runtimeIdByStoredSessionIdRef.current.set(storedSessionId, sessionId) + setRuntimeSessionMapping(storedSessionId, sessionId) if (existing.busy) { setSessionWorking(storedSessionId, true) @@ -113,6 +116,8 @@ export function useSessionStateCache({ } if (previousStoredSessionId && previousStoredSessionId !== storedSessionId) { + runtimeIdByStoredSessionIdRef.current.delete(previousStoredSessionId) + clearRuntimeSessionMapping(previousStoredSessionId, sessionId) setSessionWorking(previousStoredSessionId, false) } } @@ -125,6 +130,7 @@ export function useSessionStateCache({ if (storedSessionId) { runtimeIdByStoredSessionIdRef.current.set(storedSessionId, sessionId) + setRuntimeSessionMapping(storedSessionId, sessionId) } return created diff --git a/apps/desktop/src/app/tasks/index.tsx b/apps/desktop/src/app/tasks/index.tsx new file mode 100644 index 000000000000..638105c63655 --- /dev/null +++ b/apps/desktop/src/app/tasks/index.tsx @@ -0,0 +1,467 @@ +import { useStore } from '@nanostores/react' +import type * as React from 'react' +import { useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { sessionTitle } from '@/lib/chat-runtime' +import { cn } from '@/lib/utils' +import { $attentionSessionIds, $runtimeIdByStoredSessionId, $sessions, $workingSessionIds } from '@/store/session' +import { $subagentsBySession, activeSubagentCount, buildSubagentTree, failedSubagentCount, type SubagentNode } from '@/store/subagents' +import { $todosBySession, todoListActive } from '@/store/todos' +import type { SessionInfo } from '@/types/hermes' + +import { PAGE_INSET_X } from '../layout-constants' +import { PageSearchShell } from '../page-search-shell' +import { sessionRoute } from '../routes' +import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' + +type LiveTodoStatus = 'cancelled' | 'completed' | 'in_progress' | 'pending' + +interface LiveTaskSession { + id: string + routeId: null | string + runtimeId: null | string + session: null | SessionInfo + title: string + busy: boolean + needsInput: boolean + todos: { + id: string + content: string + status: LiveTodoStatus + }[] + activeTodoCount: number + subagents: SubagentNode[] + activeSubagentCount: number + failedSubagentCount: number + updatedAt: number +} + +const ACTIVE_TODO_STATUSES: ReadonlySet = new Set(['pending', 'in_progress']) + +function includesQuery(value: null | string | undefined, query: string): boolean { + return (value || '').toLowerCase().includes(query) +} + +function sessionLabel(session: null | SessionInfo, sessionId: string): string { + if (!session) { + return `Session ${sessionId.slice(0, 8)}` + } + + return sessionTitle(session) +} + +function sessionTimestampMs(session: null | SessionInfo): number { + if (!session) { + return 0 + } + + return Math.max(session.last_active || 0, session.started_at || 0) * 1000 +} + +function taskStatusTone(status: LiveTodoStatus): string { + switch (status) { + case 'completed': + return 'bg-emerald-500/12 text-emerald-300 border-emerald-500/20' + + case 'cancelled': + return 'bg-slate-500/12 text-slate-300 border-slate-500/20' + + case 'in_progress': + return 'bg-sky-500/12 text-sky-300 border-sky-500/20' + + case 'pending': + + default: + return 'bg-amber-500/12 text-amber-200 border-amber-500/20' + } +} + +function taskStatusLabel(status: LiveTodoStatus): string { + switch (status) { + case 'completed': + return 'Done' + + case 'cancelled': + return 'Cancelled' + + case 'in_progress': + return 'In progress' + + case 'pending': + + default: + return 'Pending' + } +} + +function subagentStatusTone(status: SubagentNode['status']): string { + switch (status) { + case 'completed': + return 'bg-emerald-500/12 text-emerald-300 border-emerald-500/20' + + case 'failed': + + case 'interrupted': + return 'bg-rose-500/12 text-rose-300 border-rose-500/20' + + case 'queued': + return 'bg-violet-500/12 text-violet-200 border-violet-500/20' + + case 'running': + + default: + return 'bg-sky-500/12 text-sky-300 border-sky-500/20' + } +} + +function subagentStatusLabel(status: SubagentNode['status']): string { + switch (status) { + case 'completed': + return 'Completed' + + case 'failed': + return 'Failed' + + case 'interrupted': + return 'Interrupted' + + case 'queued': + return 'Queued' + + case 'running': + + default: + return 'Running' + } +} + +function latestSubagentLine(node: SubagentNode): null | string { + return node.stream.at(-1)?.text?.trim() || node.summary?.trim() || null +} + +function matchesSession(entry: LiveTaskSession, query: string): boolean { + if (!query) { + return true + } + + if (includesQuery(entry.title, query) || includesQuery(entry.id, query) || includesQuery(entry.session?.cwd, query)) { + return true + } + + if (entry.todos.some(todo => includesQuery(todo.content, query) || includesQuery(todo.status, query))) { + return true + } + + const stack = [...entry.subagents] + + while (stack.length > 0) { + const node = stack.pop()! + + if ( + includesQuery(node.goal, query) || + includesQuery(node.currentTool, query) || + includesQuery(node.summary, query) || + includesQuery(latestSubagentLine(node), query) + ) { + return true + } + + stack.push(...node.children) + } + + return false +} + +interface TasksViewProps extends React.ComponentProps<'section'> { + setStatusbarItemGroup?: SetStatusbarItemGroup +} + +export function TasksView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: TasksViewProps) { + const navigate = useNavigate() + const sessions = useStore($sessions) + const todosBySession = useStore($todosBySession) + const subagentsBySession = useStore($subagentsBySession) + const workingSessionIds = useStore($workingSessionIds) + const attentionSessionIds = useStore($attentionSessionIds) + const runtimeIdByStoredSessionId = useStore($runtimeIdByStoredSessionId) + const [query, setQuery] = useState('') + + const liveSessions = useMemo(() => { + const sessionsByStoredId = new Map() + + for (const session of sessions) { + sessionsByStoredId.set(session.id, session) + + if (session._lineage_root_id) { + sessionsByStoredId.set(session._lineage_root_id, session) + } + } + + const storedIdByRuntimeSessionId = new Map(Object.entries(runtimeIdByStoredSessionId).map(([storedId, runtimeId]) => [runtimeId, storedId])) + + const sessionIds = new Set([ + ...workingSessionIds, + ...attentionSessionIds, + ...Object.keys(todosBySession).map(runtimeId => storedIdByRuntimeSessionId.get(runtimeId) ?? `runtime:${runtimeId}`), + ...Object.keys(subagentsBySession).map(runtimeId => storedIdByRuntimeSessionId.get(runtimeId) ?? `runtime:${runtimeId}`) + ]) + + const rows: LiveTaskSession[] = [] + + for (const key of sessionIds) { + const runtimeId = key.startsWith('runtime:') ? key.slice('runtime:'.length) : (runtimeIdByStoredSessionId[key] ?? null) + const storedId = key.startsWith('runtime:') ? (storedIdByRuntimeSessionId.get(runtimeId) ?? null) : key + const session = storedId ? (sessionsByStoredId.get(storedId) ?? null) : null + const todos = ((runtimeId ? todosBySession[runtimeId] : []) ?? []).filter(todo => todo.id && todo.content) + const subagents = (runtimeId ? subagentsBySession[runtimeId] : []) ?? [] + const activeTodos = todos.filter(todo => ACTIVE_TODO_STATUSES.has(todo.status)) + const activeSubagents = activeSubagentCount(subagents) + const failedSubagents = failedSubagentCount(subagents) + const busy = storedId ? workingSessionIds.includes(storedId) : false + const needsInput = storedId ? attentionSessionIds.includes(storedId) : false + const live = busy || needsInput || todoListActive(todos) || activeSubagents > 0 + + if (!live) { + continue + } + + rows.push({ + id: storedId ?? runtimeId ?? key, + routeId: session?.id ?? storedId, + runtimeId, + session, + title: sessionLabel(session, storedId ?? runtimeId ?? key), + busy, + needsInput, + todos, + activeTodoCount: activeTodos.length, + subagents: buildSubagentTree(subagents), + activeSubagentCount: activeSubagents, + failedSubagentCount: failedSubagents, + updatedAt: Math.max(sessionTimestampMs(session), ...subagents.map(item => item.updatedAt), 0) + }) + } + + return rows.sort((left, right) => { + const leftScore = Number(left.busy) * 4 + Number(left.needsInput) * 2 + Number(left.activeSubagentCount > 0 || left.activeTodoCount > 0) + const rightScore = Number(right.busy) * 4 + Number(right.needsInput) * 2 + Number(right.activeSubagentCount > 0 || right.activeTodoCount > 0) + + return rightScore - leftScore || right.updatedAt - left.updatedAt || left.title.localeCompare(right.title) + }) + }, [attentionSessionIds, runtimeIdByStoredSessionId, sessions, subagentsBySession, todosBySession, workingSessionIds]) + + const normalizedQuery = query.trim().toLowerCase() + + const visibleSessions = useMemo( + () => liveSessions.filter(entry => matchesSession(entry, normalizedQuery)), + [liveSessions, normalizedQuery] + ) + + const totals = useMemo(() => { + let todoCount = 0 + let subagentCount = 0 + let blockedCount = 0 + let busyCount = 0 + + for (const entry of liveSessions) { + todoCount += entry.activeTodoCount + subagentCount += entry.activeSubagentCount + blockedCount += Number(entry.needsInput) + busyCount += Number(entry.busy) + } + + return { + sessions: liveSessions.length, + todos: todoCount, + subagents: subagentCount, + blocked: blockedCount, + busy: busyCount + } + }, [liveSessions]) + + return ( + + + + + + + + } + onSearchChange={setQuery} + searchHidden={liveSessions.length === 0} + searchPlaceholder="Search live tasks…" + searchValue={query} + tabs={
Live tasks
} + > +
+ {visibleSessions.length === 0 ? ( + 0} /> + ) : ( +
+ {visibleSessions.map(entry => ( +
+
+
+
+

{entry.title}

+ {entry.busy && Running} + {entry.needsInput && ( + Needs input + )} + {entry.failedSubagentCount > 0 && ( + + {entry.failedSubagentCount} issue{entry.failedSubagentCount === 1 ? '' : 's'} + + )} +
+
+ {entry.activeTodoCount} active todo{entry.activeTodoCount === 1 ? '' : 's'} + + {entry.activeSubagentCount} active subagent{entry.activeSubagentCount === 1 ? '' : 's'} + {entry.session?.cwd ? ( + <> + + {entry.session.cwd} + + ) : null} +
+
+ + +
+ +
+
+ + {entry.todos.length === 0 ? ( + No live todo items for this session yet. + ) : ( +
    + {entry.todos.map(todo => ( +
  • +
    + + {taskStatusLabel(todo.status)} + +
    {todo.content}
    +
    +
  • + ))} +
+ )} +
+ +
+ + {entry.subagents.length === 0 ? ( + No background subagents are running for this session. + ) : ( +
+ {entry.subagents.map(node => ( + + ))} +
+ )} +
+
+
+ ))} +
+ )} +
+
+ ) +} + +function SummaryPill({ label, value }: { label: string; value: number }) { + return ( +
+ {label} + {value} +
+ ) +} + +function Pill({ children, className }: { children: React.ReactNode; className?: string }) { + return {children} +} + +function SectionTitle({ title }: { title: string }) { + return
{title}
+} + +function MutedPanel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function EmptyState({ hasQuery }: { hasQuery: boolean }) { + return ( +
+
+
{hasQuery ? 'No matching live tasks' : 'No live tasks right now'}
+

+ {hasQuery + ? 'Try a different search term, or wait for Hermes to start work in another session.' + : 'This page lights up automatically when Hermes starts a session, creates todo items, or spins up subagents.'} +

+
+
+ ) +} + +function SubagentTree({ node, depth = 0 }: { node: SubagentNode; depth?: number }) { + const latest = latestSubagentLine(node) + + return ( +
+
0 ? `${depth * 14}px` : undefined }} + > +
+ + {subagentStatusLabel(node.status)} + +
+
{node.goal}
+ {latest ?
{latest}
: null} +
+ {node.currentTool ? Tool: {node.currentTool} : null} + {typeof node.toolCount === 'number' ? Tools used: {node.toolCount} : null} + {typeof node.durationSeconds === 'number' ? {Math.round(node.durationSeconds)}s : null} +
+
+
+
+ + {node.children.map(child => ( + + ))} +
+ ) +} diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index 1adc2bdec4e1..6380b607f19a 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -122,7 +122,7 @@ export type CommandDispatchResponse = | SendCommandDispatchResponse | PrefillCommandDispatchResponse -export type SidebarNavId = 'artifacts' | 'command-center' | 'messaging' | 'new-session' | 'settings' | 'skills' +export type SidebarNavId = 'artifacts' | 'command-center' | 'messaging' | 'new-session' | 'settings' | 'skills' | 'tasks' export interface SidebarNavItem { id: SidebarNavId diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 351702deda3a..c5688372d686 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1305,6 +1305,7 @@ export const en: Translations = { nav: { 'new-session': 'New session', skills: 'Skills & Tools', + tasks: 'Live tasks', messaging: 'Messaging', artifacts: 'Artifacts' }, diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 2be408530541..f9240b6fc429 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -228,6 +228,7 @@ export const $messagingTruncated = atom(false) export const $sessionProfileTotals = atom>({}) export const $sessionsLoading = atom(true) export const $workingSessionIds = atom([]) +export const $runtimeIdByStoredSessionId = atom>({}) export const $activeSessionId = atom(null) export const $selectedStoredSessionId = atom(null) export const $messages = atom([]) @@ -300,6 +301,7 @@ export const setSessionProfileTotals = (next: Updater>) = updateAtom($sessionProfileTotals, next) export const setSessionsLoading = (next: Updater) => updateAtom($sessionsLoading, next) export const setWorkingSessionIds = (next: Updater) => updateAtom($workingSessionIds, next) +export const setRuntimeIdByStoredSessionId = (next: Updater>) => updateAtom($runtimeIdByStoredSessionId, next) export const setActiveSessionId = (next: Updater) => updateAtom($activeSessionId, next) export const setSelectedStoredSessionId = (next: Updater) => updateAtom($selectedStoredSessionId, next) export const setMessages = (next: Updater) => updateAtom($messages, next) @@ -523,3 +525,40 @@ export function setSessionWorking(sessionId: string | null | undefined, working: } } } + +export function setRuntimeSessionMapping(storedSessionId: null | string | undefined, runtimeSessionId: null | string | undefined) { + if (!storedSessionId || !runtimeSessionId) { + return + } + + setRuntimeIdByStoredSessionId(current => { + if (current[storedSessionId] === runtimeSessionId) { + return current + } + + return { ...current, [storedSessionId]: runtimeSessionId } + }) +} + +export function clearRuntimeSessionMapping( + storedSessionId: null | string | undefined, + runtimeSessionId?: null | string | undefined +) { + if (!storedSessionId) { + return + } + + setRuntimeIdByStoredSessionId(current => { + if (!(storedSessionId in current)) { + return current + } + + if (runtimeSessionId && current[storedSessionId] !== runtimeSessionId) { + return current + } + + const { [storedSessionId]: _drop, ...rest } = current + + return rest + }) +}