diff --git a/frontend/src/components/ActiveSessionsList.tsx b/frontend/src/components/ActiveSessionsList.tsx new file mode 100644 index 00000000..6a31cfbf --- /dev/null +++ b/frontend/src/components/ActiveSessionsList.tsx @@ -0,0 +1,103 @@ +import { useCallback } from 'react'; +import { + useSessionOverview, + type SessionActivity, + type SessionActivityState, +} from '../hooks/useSessionOverview'; + +const STATE_CONFIG: Record = { + init: { icon: '\u25CB', color: '#888', label: 'init' }, + working: { icon: '\u25CF', color: '#b48cff', label: 'working' }, + waiting: { icon: '\u26A0', color: '#ff6d6d', label: 'waiting' }, + done: { icon: '\u2713', color: '#4ade80', label: 'done' }, + idle: { icon: '\u25CB', color: '#555', label: 'idle' }, + paused: { icon: '\u23F8', color: '#888', label: 'paused' }, +}; + +function formatElapsed(ms: number): string { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + return `${hours}h`; +} + +function ActivityCard({ + activity, + isActive, + onTap, +}: { + activity: SessionActivity; + isActive: boolean; + onTap: (sessionId: string) => void; +}) { + const config = STATE_CONFIG[activity.state]; + const elapsed = Date.now() - activity.lastEventAt; + + let metaText = config.label; + if (activity.waitReason === 'permission') metaText = 'permission'; + else if (activity.waitReason === 'review') metaText = 'review needed'; + else if (activity.waitReason === 'blocked') metaText = 'blocked'; + if (activity.progress) { + metaText += ` \u00B7 ${activity.progress.done}/${activity.progress.total}`; + } + metaText += ` \u00B7 ${formatElapsed(elapsed)}`; + + return ( + + ); +} + +export interface ActiveSessionsListProps { + activeSessionId?: string; + onSelectSession: (id: string) => void; +} + +export function ActiveSessionsList({ activeSessionId, onSelectSession }: ActiveSessionsListProps) { + const { activities, attendCount } = useSessionOverview(); + + const visible = activities.filter((a) => a.state !== 'idle' && a.state !== 'init'); + + const handleTap = useCallback( + (sessionId: string) => { + onSelectSession(sessionId); + }, + [onSelectSession], + ); + + if (visible.length === 0) { + return

No active sessions

; + } + + return ( +
+ {attendCount > 0 && ( +
+ {attendCount} need{attendCount === 1 ? 's' : ''} attention +
+ )} + {visible.map((a) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/CollapsibleSection.tsx b/frontend/src/components/CollapsibleSection.tsx new file mode 100644 index 00000000..7b0f392d --- /dev/null +++ b/frontend/src/components/CollapsibleSection.tsx @@ -0,0 +1,61 @@ +import { useState, type ReactNode } from 'react'; + +export interface CollapsibleSectionProps { + title: string; + badge?: number; + storageKey: string; + children: ReactNode; + defaultOpen?: boolean; + actions?: ReactNode; +} + +function readCollapsed(key: string, defaultOpen: boolean): boolean { + try { + const val = localStorage.getItem(key); + if (val === null) return !defaultOpen; + return val === '1'; + } catch { + return !defaultOpen; + } +} + +export function CollapsibleSection({ + title, + badge, + storageKey, + children, + defaultOpen = true, + actions, +}: CollapsibleSectionProps) { + const [collapsed, setCollapsed] = useState(() => readCollapsed(storageKey, defaultOpen)); + + function toggle() { + setCollapsed((prev) => { + const next = !prev; + try { + localStorage.setItem(storageKey, next ? '1' : '0'); + } catch { + /* ignore */ + } + return next; + }); + } + + return ( +
+ + {!collapsed &&
{children}
} +
+ ); +} diff --git a/frontend/src/components/CommandCenter.tsx b/frontend/src/components/CommandCenter.tsx new file mode 100644 index 00000000..34774005 --- /dev/null +++ b/frontend/src/components/CommandCenter.tsx @@ -0,0 +1,13 @@ +import { InboxSection } from './InboxSection'; +import { TelosSection } from './TelosSection'; +import { TaskBoardSection } from './TaskBoardSection'; + +export function CommandCenter() { + return ( +
+ + + +
+ ); +} diff --git a/frontend/src/components/DesktopNav.tsx b/frontend/src/components/DesktopNav.tsx index 1eae98f2..08d0dec7 100644 --- a/frontend/src/components/DesktopNav.tsx +++ b/frontend/src/components/DesktopNav.tsx @@ -1,18 +1,16 @@ import { useLocation, useNavigate } from 'react-router-dom'; -import { useTabBadges } from '../hooks/useTabBadges'; interface NavItem { label: string; path: string; match: (pathname: string) => boolean; - badge?: number; } export function DesktopNav() { const location = useLocation(); const navigate = useNavigate(); - const { inboxCount, todoCount } = useTabBadges(); + // Nav only lists full-page routes; sidebar widgets (Inbox, Telos, Tasks) are in CommandCenter. const items: NavItem[] = [ { label: 'Chat', @@ -20,14 +18,6 @@ export function DesktopNav() { match: (p) => p === '/' || p === '/chat' || p.startsWith('/chat/'), }, { label: 'Calendar', path: '/calendar', match: (p) => p.startsWith('/calendar') }, - { label: 'Inbox', path: '/inbox', match: (p) => p === '/inbox', badge: inboxCount }, - { - label: 'Telos', - path: '/todos', - match: (p) => p === '/todos' || p.startsWith('/todos/'), - badge: todoCount, - }, - { label: 'Tasks', path: '/tasks', match: (p) => p.startsWith('/tasks') }, { label: 'Files', path: '/files', match: (p) => p.startsWith('/files') }, ]; @@ -40,7 +30,6 @@ export function DesktopNav() { onClick={() => navigate(item.path)} > {item.label} - {item.badge ? {item.badge} : null} ))} diff --git a/frontend/src/components/InboxSection.tsx b/frontend/src/components/InboxSection.tsx new file mode 100644 index 00000000..d18a0ed9 --- /dev/null +++ b/frontend/src/components/InboxSection.tsx @@ -0,0 +1,142 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useMitzoStore } from '@mitzo/client/hooks'; +import { apiFetch } from '../lib/api-fetch'; +import { buildInboxPrompt, buildInboxContext } from '../lib/inbox-utils'; +import { CollapsibleSection } from './CollapsibleSection'; + +interface InboxItem { + filename: string; + agent: string; + title: string; + tags: string[]; + timestamp: string; + preview: string; +} + +export function InboxSection() { + const navigate = useNavigate(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [pendingRemovals, setPendingRemovals] = useState>(new Set()); + const setPendingSession = useMitzoStore((s) => s.setPendingSession); + const storeInbox = useMitzoStore((s) => s.inbox.items); + const loadInbox = useMitzoStore((s) => s.loadInbox); + + useEffect(() => { + loadInbox().then(() => setLoading(false)); + }, [loadInbox]); + + // Sync store inbox to local state, filtering optimistic removals. + // Derive the pruned set inline so filtering uses the up-to-date value + // instead of the stale closure captured before setPendingRemovals runs. + useEffect(() => { + const serverFilenames = new Set((storeInbox as InboxItem[]).map((i) => i.filename)); + setPendingRemovals((prev) => { + const pruned = new Set(); + for (const f of prev) { + if (serverFilenames.has(f)) pruned.add(f); + } + return pruned.size === prev.size ? prev : pruned; + }); + }, [storeInbox]); + + // Derive visible items from store + pending removals (both are reactive) + useEffect(() => { + const filtered = (storeInbox as InboxItem[]).filter( + (item) => !pendingRemovals.has(item.filename), + ); + setItems(filtered); + }, [storeInbox, pendingRemovals]); + + const handleApprove = useCallback( + (filename: string) => { + setPendingRemovals((prev) => new Set(prev).add(filename)); + setItems((prev) => prev.filter((i) => i.filename !== filename)); + apiFetch(`/api/inbox/${encodeURIComponent(filename)}/approve`, { method: 'POST' }) + .then((res) => { + if (!res.ok) loadInbox(); + }) + .catch(() => loadInbox()) + .finally(() => { + setPendingRemovals((prev) => { + const next = new Set(prev); + next.delete(filename); + return next; + }); + }); + }, + [loadInbox], + ); + + const handleDiscard = useCallback( + (filename: string) => { + setPendingRemovals((prev) => new Set(prev).add(filename)); + setItems((prev) => prev.filter((i) => i.filename !== filename)); + apiFetch(`/api/inbox/${encodeURIComponent(filename)}`, { method: 'DELETE' }) + .then((res) => { + if (!res.ok) loadInbox(); + }) + .catch(() => loadInbox()) + .finally(() => { + setPendingRemovals((prev) => { + const next = new Set(prev); + next.delete(filename); + return next; + }); + }); + }, + [loadInbox], + ); + + const handleStartSession = useCallback( + (item: InboxItem) => { + setPendingSession({ + prompt: buildInboxPrompt(item, item.preview), + context: buildInboxContext(item, item.preview), + }); + navigate('/chat'); + }, + [navigate, setPendingSession], + ); + + return ( + + {loading &&

Loading...

} + {!loading && items.length === 0 &&

No pending proposals

} + {items.map((item) => ( +
+
+
+ {item.agent} {item.title} +
+
{item.preview}
+
+
+ + + +
+
+ ))} +
+ ); +} diff --git a/frontend/src/components/SessionPanel.tsx b/frontend/src/components/SessionPanel.tsx index d9f8734c..2afd994d 100644 --- a/frontend/src/components/SessionPanel.tsx +++ b/frontend/src/components/SessionPanel.tsx @@ -1,4 +1,7 @@ +import { useState, useCallback } from 'react'; import { useSessionList } from '../hooks/useSessionList'; +import { useSessionOverview } from '../hooks/useSessionOverview'; +import { ActiveSessionsList } from './ActiveSessionsList'; import { formatRelativeTime } from '../lib/formatTime'; export interface SessionPanelProps { @@ -7,8 +10,31 @@ export interface SessionPanelProps { onNewChat: () => void; } +type ViewMode = 'active' | 'all'; + +const STORAGE_KEY = 'mitzo-session-view-mode'; + +function readViewMode(): ViewMode { + try { + return (localStorage.getItem(STORAGE_KEY) as ViewMode) || 'active'; + } catch { + return 'active'; + } +} + export function SessionPanel({ activeSessionId, onSelectSession, onNewChat }: SessionPanelProps) { const { sessions, loading, dismissSession } = useSessionList(); + const { attendCount } = useSessionOverview(); + const [viewMode, setViewMode] = useState(readViewMode); + + const switchView = useCallback((mode: ViewMode) => { + setViewMode(mode); + try { + localStorage.setItem(STORAGE_KEY, mode); + } catch { + /* ignore */ + } + }, []); return (
@@ -16,40 +42,66 @@ export function SessionPanel({ activeSessionId, onSelectSession, onNewChat }: Se New Chat - {loading &&

Loading...

} - - {!loading && sessions.length === 0 &&

No sessions

} - - {!loading && sessions.length > 0 && ( -
- {sessions.map((s) => ( -
onSelectSession(s.id)} - > -
-
{s.summary || 'Untitled session'}
-
- - {formatRelativeTime(s.lastModified)} - - {s.branch && {s.branch}} +
+ + +
+ + {viewMode === 'active' ? ( + + ) : ( + <> + {loading &&

Loading...

} + + {!loading && sessions.length === 0 &&

No sessions

} + + {!loading && sessions.length > 0 && ( +
+ {sessions.map((s) => ( +
onSelectSession(s.id)} + > + {s.isActive && ( + + )} +
+
+ {s.summary || 'Untitled session'} +
+
+ + {formatRelativeTime(s.lastModified)} + + {s.branch && {s.branch}} +
+
+
-
- + ))}
- ))} -
+ )} + )}
); diff --git a/frontend/src/components/TaskBoardSection.tsx b/frontend/src/components/TaskBoardSection.tsx new file mode 100644 index 00000000..376aecdb --- /dev/null +++ b/frontend/src/components/TaskBoardSection.tsx @@ -0,0 +1,149 @@ +import { useCallback } from 'react'; +import { useTaskBoard } from '../hooks/useTaskBoard'; +import { TaskNode } from './TaskNode'; +import { CollapsibleSection } from './CollapsibleSection'; +import type { TaskStatus } from '../types/task'; + +const STATE_LABELS: Record = { + idle: 'Idle', + running: 'Running', + paused: 'Paused', +}; + +const STATE_COLORS: Record = { + idle: '#888', + running: '#b48cff', + paused: '#fbbf24', +}; + +export function TaskBoardSection() { + const { + loading, + tasks, + loopStatus, + updateTask, + deleteTask, + pauseLoop, + resumeLoop, + stopLoop, + approveTask, + rejectTask, + approveSpec, + rejectSpec, + refresh, + } = useTaskBoard(); + + const handleStatusChange = useCallback( + (id: string, status: TaskStatus) => { + updateTask(id, { status }); + }, + [updateTask], + ); + + const handleDelete = useCallback( + (id: string) => { + deleteTask(id); + }, + [deleteTask], + ); + + const { state, progress, awaitingApproval } = loopStatus; + + // Count items needing attention + const needsAttention = tasks.filter( + (t) => t.status === 'pending_review' || t.status === 'blocked' || t.status === 'failed', + ).length; + + return ( + + ↻ + + } + > + {/* Compact loop bar */} + {state !== 'idle' && ( +
+
+ + {STATE_LABELS[state]} + {progress && ( + + {progress.done}/{progress.total} + + )} +
+
+ {state === 'running' && !awaitingApproval && ( + <> + + + + )} + {state === 'paused' && !awaitingApproval && ( + <> + + + + )} +
+ {progress && progress.total > 0 && ( +
+
+
+ )} +
+ )} + + {/* Spec approval gate */} + {awaitingApproval && ( +
+

Task breakdown ready

+
+ + +
+
+ )} + + {loading &&

Loading...

} + + {!loading && tasks.length === 0 && state === 'idle' &&

No tasks

} + + {/* Task tree */} +
+ {tasks.map((task) => ( + + ))} +
+ + ); +} diff --git a/frontend/src/components/TaskNode.tsx b/frontend/src/components/TaskNode.tsx index de29db4b..43dd8334 100644 --- a/frontend/src/components/TaskNode.tsx +++ b/frontend/src/components/TaskNode.tsx @@ -33,7 +33,7 @@ interface TaskNodeProps { activeTaskId?: string | null; onStatusChange: (id: string, status: TaskStatus) => void; onDelete: (id: string) => void; - onAddChild: (parentId: string) => void; + onAddChild?: (parentId: string) => void; onApprove?: (id: string) => void; onReject?: (id: string, feedback: string) => void; } @@ -105,13 +105,15 @@ export function TaskNode({ ✗ )} - + {onAddChild && ( + + )} + )} + +
+
+ ); +} + +export function TelosSection() { + const navigate = useNavigate(); + const [activeProfile, setActiveProfile] = useState(undefined); + const { loading, items, profiles, ack, done, create, refresh } = useTodoData(activeProfile); + const [creating, setCreating] = useState(false); + const [newSummary, setNewSummary] = useState(''); + + const handleTap = useCallback( + (item: TodoItem) => { + navigate(`/todos/${item.id}`, { state: { item, activeProfile } }); + }, + [navigate, activeProfile], + ); + + // Group by tier: starred active (focus) > active > acknowledged > rest + const focus = items.filter((i) => i.status === 'active' && i.starred); + const active = items.filter((i) => i.status === 'active' && !i.starred); + const seen = items.filter((i) => i.status === 'acknowledged'); + + const totalActive = focus.length + active.length; + + const handleCreate = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + const text = newSummary.trim(); + if (!text) return; + await create(text, activeProfile || profiles[0] || 'work'); + setNewSummary(''); + setCreating(false); + }, + [newSummary, create, activeProfile, profiles], + ); + + return ( + + + + + } + > + {profiles.length > 1 && ( +
+ + {profiles.map((p) => ( + + ))} +
+ )} + + {creating && ( +
+ setNewSummary(e.target.value)} + placeholder="New item..." + autoFocus + onKeyDown={(e) => { + if (e.key === 'Escape') setCreating(false); + }} + /> + +
+ )} + + {loading &&

Loading...

} + + {!loading && items.length === 0 &&

No active items

} + + {focus.length > 0 && ( +
+
Focus ({focus.length})
+ {focus.map((item) => ( + + ))} +
+ )} + + {active.length > 0 && ( +
+
Active ({active.length})
+ {active.map((item) => ( + + ))} +
+ )} + + {seen.length > 0 && ( +
+
Seen ({seen.length})
+ {seen.map((item) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/DesktopChatView.tsx b/frontend/src/pages/DesktopChatView.tsx index b520448a..cd5c734b 100644 --- a/frontend/src/pages/DesktopChatView.tsx +++ b/frontend/src/pages/DesktopChatView.tsx @@ -1,10 +1,8 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { useParams, useSearchParams, useNavigate } from 'react-router-dom'; -import { apiFetch } from '../lib/api-fetch'; import { DesktopShell } from '../components/DesktopShell'; import { SessionPanel } from '../components/SessionPanel'; -import { ContextPanel } from '../components/ContextPanel'; -import { FileBrowserPanel } from '../components/FileBrowserPanel'; +import { CommandCenter } from '../components/CommandCenter'; import { ChatArea } from '../components/ChatArea'; import { ChatInput } from '../components/ChatInput'; import { ScrollFab } from '../components/ScrollFab'; @@ -16,8 +14,6 @@ import { getPreferredModel, setPreferredModel } from '../lib/model-preference'; import { useVoice } from '../hooks/useVoice'; import { useAutoSpeak } from '../hooks/useAutoSpeak'; import { useProgressByToolId } from '../hooks/useProgress'; -import type { FileRoot } from '../components/FileBrowserPanel'; -import type { ContextBlockEntry } from '../components/ContextPicker'; import type { ImageAttachment } from '../types/chat'; export function DesktopChatView() { @@ -25,32 +21,6 @@ export function DesktopChatView() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const [contextBlocks, setContextBlocks] = useState([]); - - // Shared config fetch for right-panel children - const [configBlocks, setConfigBlocks] = useState([]); - const [fileRoots, setFileRoots] = useState([]); - const [configLoaded, setConfigLoaded] = useState(false); - - useEffect(() => { - apiFetch('/api/config') - .then((r) => r.json()) - .then((data) => { - const entries: ContextBlockEntry[] = []; - if (data.contextBlocks) { - for (const [name, info] of Object.entries( - data.contextBlocks as Record, - )) { - entries.push({ name, path: info.path, sizeBytes: info.sizeBytes }); - } - } - setConfigBlocks(entries); - setFileRoots(data.fileViewerRoots ?? []); - setConfigLoaded(true); - }) - .catch(() => setConfigLoaded(true)); - }, []); - // Store state const messages = useMessages(); const connection = useConnection(); @@ -149,10 +119,6 @@ export function DesktopChatView() { // ── Actions ────────────────────────────────────────────────────────────── function handleSend(text: string, images?: ImageAttachment[], ctxBlocks?: string[]): boolean { - // For new sessions (no activeSessionId) the store bootstraps a WS on - // demand inside sendMessage(), so we must not block on connection status. - // Only gate on connection for existing sessions where a WS should already - // be open. if (activeSessionId && connection.status !== 'connected') { storeDispatchMessages({ type: 'CONNECTION_LOST' }); return false; @@ -195,12 +161,6 @@ export function DesktopChatView() { storeSetMode(newMode); } - const handleToggleContext = useCallback((name: string) => { - setContextBlocks((prev) => - prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name], - ); - }, []); - const handleSelectSession = useCallback((id: string) => navigate(`/chat/${id}`), [navigate]); const handleNewChat = useCallback(() => { storeNewSession(); @@ -291,22 +251,11 @@ export function DesktopChatView() { isWorktree={messages.isWorktree} wtId={messages.wtId || undefined} sessionId={activeSessionId ?? undefined} - externalContextBlocks={contextBlocks} tokenState={tokens} />
} - right={ -
- - -
- } + right={} statusBar={