Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions frontend/src/components/ActiveSessionsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { useCallback } from 'react';
import {
useSessionOverview,
type SessionActivity,
type SessionActivityState,
} from '../hooks/useSessionOverview';

const STATE_CONFIG: Record<SessionActivityState, { icon: string; color: string; label: string }> = {
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 (
<button
className={`cc-card cc-card--${activity.state}${isActive ? ' cc-card--current' : ''}`}
onClick={() => onTap(activity.sessionId)}
>
<span className="cc-card-icon" style={{ color: config.color }}>
{config.icon}
</span>
<div className="cc-card-content">
<div className="cc-card-title">
{activity.repo && <span className="cc-card-repo">{activity.repo}:</span>} {activity.title}
</div>
<div className="cc-card-meta">{metaText}</div>
</div>
</button>
);
}

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 <p className="session-panel-empty">No active sessions</p>;
}

return (
<div className="active-sessions-list">
{attendCount > 0 && (
<div className="active-sessions-summary">
{attendCount} need{attendCount === 1 ? 's' : ''} attention
</div>
)}
{visible.map((a) => (
<ActivityCard
key={a.sessionId}
activity={a}
isActive={a.sessionId === activeSessionId}
onTap={handleTap}
/>
))}
</div>
);
}
61 changes: 61 additions & 0 deletions frontend/src/components/CollapsibleSection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="cc-section">
<button className="cc-section-header" onClick={toggle}>
<span className="cc-section-title">{title}</span>
{badge !== undefined && badge > 0 && <span className="cc-section-badge">{badge}</span>}
{actions && (
<span className="cc-section-actions" onClick={(e) => e.stopPropagation()}>
{actions}
</span>
)}
<span className={`cc-section-chevron${collapsed ? '' : ' cc-section-chevron--open'}`}>
&rsaquo;
</span>
</button>
{!collapsed && <div className="cc-section-body">{children}</div>}
</div>
);
}
13 changes: 13 additions & 0 deletions frontend/src/components/CommandCenter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { InboxSection } from './InboxSection';
import { TelosSection } from './TelosSection';
import { TaskBoardSection } from './TaskBoardSection';

export function CommandCenter() {
return (
<div className="command-center">
<InboxSection />
<TelosSection />
<TaskBoardSection />
</div>
);
}
13 changes: 1 addition & 12 deletions frontend/src/components/DesktopNav.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,23 @@
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',
path: '/',
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') },
];

Expand All @@ -40,7 +30,6 @@ export function DesktopNav() {
onClick={() => navigate(item.path)}
>
<span>{item.label}</span>
{item.badge ? <span className="desktop-nav-badge">{item.badge}</span> : null}
</button>
))}
</nav>
Expand Down
142 changes: 142 additions & 0 deletions frontend/src/components/InboxSection.tsx
Original file line number Diff line number Diff line change
@@ -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<InboxItem[]>([]);
const [loading, setLoading] = useState(true);
const [pendingRemovals, setPendingRemovals] = useState<Set<string>>(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<string>();
for (const f of prev) {
if (serverFilenames.has(f)) pruned.add(f);
}
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 unsafe_assumptions: The useEffect calls setPendingRemovals (scheduling a state update) then immediately reads the stale pendingRemovals closure value to filter items. On the first render after store changes, items may briefly include entries that should be hidden by pending removals. The effect self-corrects on the next cycle (since pendingRemovals is a dependency), but the intermediate state is visible. Consider computing the filtered list inside the setPendingRemovals updater or using a ref for pending removals. [fixable]

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 (
<CollapsibleSection title="Inbox" badge={items.length || undefined} storageKey="cc-inbox">
{loading && <p className="cc-empty">Loading...</p>}
{!loading && items.length === 0 && <p className="cc-empty">No pending proposals</p>}
{items.map((item) => (
<div key={item.filename} className="cc-card cc-card--inbox">
<div className="cc-card-content">
<div className="cc-card-title">
<span className="cc-card-agent">{item.agent}</span> {item.title}
</div>
<div className="cc-card-meta">{item.preview}</div>
</div>
<div className="cc-card-actions">
<button
className="cc-btn cc-btn--approve"
onClick={() => handleApprove(item.filename)}
title="Approve"
>
&#x2713;
</button>
<button
className="cc-btn cc-btn--danger"
onClick={() => handleDiscard(item.filename)}
title="Discard"
>
&#x2717;
</button>
<button
className="cc-btn"
onClick={() => handleStartSession(item)}
title="Start session"
>
&#x25B6;
</button>
</div>
</div>
))}
</CollapsibleSection>
);
}
Loading
Loading