diff --git a/frontend/src/components/AttentionFeed.tsx b/frontend/src/components/AttentionFeed.tsx new file mode 100644 index 00000000..aed65ea0 --- /dev/null +++ b/frontend/src/components/AttentionFeed.tsx @@ -0,0 +1,96 @@ +import { useState, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAttentionFeed, type AttentionItem } from '../hooks/useAttentionFeed'; +import { selectionChanged } from '../lib/haptics'; + +// ─── Source labels ───────────────────────────────────────────────────────── + +const SOURCE_LABEL: Record = { + telos: 'telos', + atb: 'task', + session: 'session', +}; + +// ─── Card ────────────────────────────────────────────────────────────────── + +function AttentionCard({ + item, + onTap, +}: { + item: AttentionItem; + onTap: (item: AttentionItem) => void; +}) { + return ( + + ); +} + +// ─── Main component ──────────────────────────────────────────────────────── + +export function AttentionFeed() { + const { items, tier1Count, loading } = useAttentionFeed(); + const navigate = useNavigate(); + + const hasUrgent = tier1Count > 0; + const [manualOpen, setManualOpen] = useState(null); + const isOpen = manualOpen ?? true; // always open by default + + const toggleOpen = useCallback(() => { + setManualOpen((prev) => !(prev ?? true)); + }, []); + + const handleTap = useCallback( + (item: AttentionItem) => { + selectionChanged(); + navigate(item.navigateTo); + }, + [navigate], + ); + + // Show section even when empty — gives "all clear" signal + const summaryParts: string[] = []; + if (tier1Count > 0) summaryParts.push(`${tier1Count} needs you`); + const t2Count = items.filter((i) => i.tier === 2).length; + if (t2Count > 0) summaryParts.push(`${t2Count} in focus`); + + return ( +
+ + {isOpen && ( +
+ {!loading && items.length === 0 && ( +
Nothing needs your attention right now.
+ )} + {items.map((item) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/TaskNode.tsx b/frontend/src/components/TaskNode.tsx index d4e62857..c55a96c6 100644 --- a/frontend/src/components/TaskNode.tsx +++ b/frontend/src/components/TaskNode.tsx @@ -38,6 +38,45 @@ const NEXT_STATUS: Record = { failed: 'pending', }; +const T1_STATUSES: TaskStatus[] = ['pending_review', 'blocked', 'failed']; + +function statusMeta(task: Task): string { + switch (task.status) { + case 'active': + return task.sessionId ? `session ${task.sessionId.slice(-6)}` : 'in progress'; + case 'pending_review': + return 'awaiting approval'; + case 'blocked': + return task.annotations?.[0] || 'blocked'; + case 'failed': + return `failed${task.retryCount > 0 ? ` \u00B7 retry ${task.retryCount}/${task.maxRetries}` : ''}`; + case 'done': + return task.completedAt ? formatElapsed(Date.now() - task.completedAt) : 'done'; + default: + return task.status; + } +} + +function formatElapsed(ms: number): string { + const sec = Math.floor(ms / 1000); + if (sec < 60) return 'just now'; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}min ago`; + const hr = Math.floor(min / 60); + return `${hr}h ago`; +} + +/** Opacity for done tasks: fade after 5min, near-invisible after 30min */ +function doneOpacity(task: Task): number { + if (task.status !== 'done' || !task.completedAt) return 1; + const elapsed = Date.now() - task.completedAt; + const FIVE_MIN = 5 * 60 * 1000; + const THIRTY_MIN = 30 * 60 * 1000; + if (elapsed < FIVE_MIN) return 1; + if (elapsed > THIRTY_MIN) return 0.3; + return 1 - 0.7 * ((elapsed - FIVE_MIN) / (THIRTY_MIN - FIVE_MIN)); +} + interface TaskNodeProps { task: Task; depth: number; diff --git a/frontend/src/components/TodoCard.tsx b/frontend/src/components/TodoCard.tsx index 348fd3e3..0e972b8c 100644 --- a/frontend/src/components/TodoCard.tsx +++ b/frontend/src/components/TodoCard.tsx @@ -14,11 +14,38 @@ interface TodoCardProps { onStartSession: (item: TodoItem) => void; } -function urgencyBar(urgency: number): string { - if (urgency >= 0.8) return '\u2593\u2593\u2593'; - if (urgency >= 0.5) return '\u2593\u2593\u2591'; - if (urgency >= 0.2) return '\u2593\u2591\u2591'; - return '\u2591\u2591\u2591'; +// ─── Urgency → color border ──────────────────────────────────────────────── + +function urgencyColor(urgency: number): string { + if (urgency >= 0.8) return '#ff6d6d'; + if (urgency >= 0.5) return '#fbbf24'; + if (urgency >= 0.2) return '#b48cff'; + return 'transparent'; +} + +function urgencyWidth(urgency: number): number { + if (urgency >= 0.8) return 4; + if (urgency >= 0.5) return 3; + if (urgency >= 0.2) return 2; + return 0; +} + +// ─── Status visuals ──────────────────────────────────────────────────────── + +function getStatusIcon(item: TodoItem): string { + if (item.starred) return '\u2605'; // ★ + if (item.status === 'active') return '\u25CF'; // ● + if (item.status === 'acknowledged') return '\u25D0'; // ◐ + if (item.status === 'completed') return '\u2713'; // ✓ + return '\u25CB'; // ○ (snoozed) +} + +function getStatusColor(item: TodoItem): string { + if (item.starred) return '#fbbf24'; + if (item.status === 'active') return '#b48cff'; + if (item.status === 'acknowledged') return '#60a5fa'; + if (item.status === 'completed') return '#4ade80'; + return '#888'; } export function TodoCard({ @@ -102,9 +129,12 @@ export function TodoCard({ const source = item.sources[0]; const ageLabel = item.ageDays === 0 ? 'new' : `${item.ageDays}d`; - const statusIcon = item.status === 'active' ? '\u25CF' : '\u25D0'; const children = item.children ?? []; const hasChildren = children.length > 0; + const icon = getStatusIcon(item); + const color = getStatusColor(item); + const borderClr = urgencyColor(item.urgency); + const borderW = urgencyWidth(item.urgency); return (
0 ? 'todo-card-tree-node--child' : ''}`}> @@ -119,8 +149,16 @@ export function TodoCard({ onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} + style={{ + borderLeftColor: borderClr, + borderLeftWidth: borderW > 0 ? `${borderW}px` : undefined, + borderLeftStyle: borderW > 0 ? 'solid' : undefined, + }} > -
+ {/* Line 1: icon + summary + star */} +
+ {icon} + {item.summary} {hasChildren && (
-
{item.summary}
- {source && ( -
- {source.author} -
- )} -
+ + {/* Line 2: source + meta */} +
+ {source ? ( + {sourceIcon(source.type)} + ) : ( + + + )} + {source?.author && ( + <>{source.author}{' \u00B7 '} + )} + {ageLabel} + {' \u00B7 '} + {item.profile} + {hasChildren && ( + <> + {' \u00B7 '} + + {item.completedChildCount ?? 0}/{item.childCount ?? children.length} + + + )} +
+ + {/* Line 3: actions */} +
+ ); +} + +// ─── Main view ───────────────────────────────────────────────────────────── + export function TodoView() { const navigate = useNavigate(); const location = useLocation(); @@ -84,6 +156,11 @@ export function TodoView() { const [creating, setCreating] = useState<{ parentId?: string } | null>(null); const setPendingSession = useMitzoStore((s) => s.setPendingSession); const scrollRef = useRef(null); + const [collapsedSections, setCollapsedSections] = useState>({ + done: true, + }); + + const sections = useMemo(() => groupIntoSections(items), [items]); // Restore scroll position when returning from detail view useEffect(() => { @@ -97,6 +174,10 @@ export function TodoView() { return scrollRef.current?.scrollTop ?? 0; }, []); + function toggleSection(key: string) { + setCollapsedSections((prev) => ({ ...prev, [key]: !prev[key] })); + } + function handleStartSession(item: TodoItem) { setPendingSession({ prompt: buildPrompt(item), @@ -175,24 +256,35 @@ export function TodoView() { /> )} -
- {items.length > 0 && Tap to start working. Swipe right = seen, left = done.} -
- -
- {items.map((item) => ( - - ))} -
+ {sections.map((section) => { + const isCollapsed = collapsedSections[section.key] ?? section.defaultCollapsed; + return ( +
+ toggleSection(section.key)} + /> + {!isCollapsed && ( +
+ {section.items.map((item) => ( + + ))} +
+ )} +
+ ); + })}
); diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index d20f066a..412c8b0a 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -753,6 +753,80 @@ textarea:focus { margin-top: 2px; } +/* ─── Attention Feed ────────────────────────────────────────────────────── */ + +.attention-section { + margin-bottom: var(--space-4); +} + +.attention-cards { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding-top: var(--space-2); +} + +.attention-card { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + background: var(--surface); + border: 1px solid var(--border); + border-left: 3px solid var(--card-accent, var(--border)); + border-radius: var(--radius-md); + cursor: pointer; + text-align: left; + color: var(--text); + font-family: inherit; + font-size: var(--text-sm); + width: 100%; + -webkit-tap-highlight-color: transparent; + transition: background 0.15s; +} + +.attention-card:active { + background: var(--hover); +} + +.attention-card-icon { + flex-shrink: 0; + font-size: var(--text-base); + line-height: 1.3; +} + +.attention-card-content { + flex: 1; + min-width: 0; +} + +.attention-card-title { + font-size: var(--text-sm); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.attention-card-meta { + font-size: var(--text-xs); + color: var(--text-dim); + margin-top: 2px; +} + +.attention-card-source { + font-weight: 600; + text-transform: uppercase; + font-size: var(--text-2xs); + letter-spacing: 0.03em; +} + +.attention-empty { + font-size: var(--text-xs); + color: var(--text-dim); + padding: var(--space-3) 0; + text-align: center; +} + .service-status { display: flex; gap: var(--space-4); @@ -4037,24 +4111,42 @@ textarea:focus { padding: 12px 14px; cursor: pointer; touch-action: pan-y; + display: flex; + flex-direction: column; + gap: 4px; } -.todo-card-header { +/* ─── 3-line card layout ──────────────────────────────────────────── */ + +.todo-card-line1 { display: flex; - align-items: center; + align-items: flex-start; gap: var(--space-2); - margin-bottom: var(--space-1); - font-size: var(--text-xs); } -.todo-card-status { - color: var(--accent); +.todo-card-icon { + flex-shrink: 0; + font-size: var(--text-base); + line-height: 1.3; } -.todo-card-urgency { - font-family: monospace; - letter-spacing: 1px; +.todo-card-summary { + flex: 1; + font-size: var(--text-sm); + line-height: 1.35; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.todo-card-line2 { + display: flex; + align-items: center; + gap: var(--space-1); + font-size: var(--text-xs); color: var(--text-dim); + padding-left: calc(var(--text-base) + var(--space-2)); } .todo-card-source { @@ -4068,22 +4160,72 @@ textarea:focus { .todo-card-age { color: var(--text-dim); - margin-left: auto; } -.todo-card-summary { - font-size: var(--text-sm); - line-height: 1.35; +.todo-card-profile { + color: var(--text-dim); } -.todo-card-meta { - margin-top: var(--space-1); - font-size: var(--text-xxs); +.todo-card-author { color: var(--text-dim); } -.todo-card-author { +.todo-card-line3 { + display: flex; + gap: var(--space-2); + padding-left: calc(var(--text-base) + var(--space-2)); + margin-top: 2px; +} + +/* ─── Section headers ─────────────────────────────────────────────── */ + +.todo-section { + margin-bottom: var(--space-3); +} + +.todo-section-header { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: var(--space-2) 0; + background: transparent; + border: none; color: var(--text-dim); + font-size: var(--text-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +.todo-section-label { + flex-shrink: 0; +} + +.todo-section-count { + flex-shrink: 0; + font-weight: 400; + opacity: 0.7; +} + +.todo-section-line { + flex: 1; + height: 1px; + background: var(--border); +} + +.todo-section-chevron { + display: inline-block; + font-size: var(--text-base); + transition: transform 0.2s; + transform: rotate(0deg); + flex-shrink: 0; +} + +.todo-section-chevron--open { + transform: rotate(90deg); } /* --- Hierarchical todo tree --- */ @@ -4132,7 +4274,6 @@ textarea:focus { padding: 2px 8px; border-radius: 10px; cursor: pointer; - margin-top: var(--space-1); opacity: 0.4; transition: opacity 0.15s; } @@ -4143,12 +4284,6 @@ textarea:focus { opacity: 1; } -.todo-card-actions { - display: flex; - gap: var(--space-2); - margin-top: var(--space-1); -} - .todo-card-session-btn { background: none; border: 1px solid var(--accent, #6366f1); @@ -4751,11 +4886,17 @@ textarea:focus { .task-node-body { flex: 1; min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; } .task-node-title { font-size: var(--text-md); line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .task-node-title--done { @@ -4780,6 +4921,12 @@ textarea:focus { color: var(--task-failed); } +.task-node-meta { + font-size: var(--text-xs); + color: var(--text-dim); + line-height: 1.3; +} + .task-node-actions { display: flex; gap: var(--space-1);