Skip to content
Open
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
96 changes: 96 additions & 0 deletions frontend/src/components/AttentionFeed.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
telos: 'telos',
atb: 'task',
session: 'session',
};

// ─── Card ──────────────────────────────────────────────────────────────────

function AttentionCard({
item,
onTap,
}: {
item: AttentionItem;
onTap: (item: AttentionItem) => void;
}) {
return (
<button
className="attention-card"
onClick={() => onTap(item)}
style={{ '--card-accent': item.accentColor } as React.CSSProperties}
>
<span className="attention-card-icon" style={{ color: item.accentColor }}>
{item.icon}
</span>
<div className="attention-card-content">
<div className="attention-card-title">{item.title}</div>
<div className="attention-card-meta">
<span className="attention-card-source">{SOURCE_LABEL[item.source]}</span>
{' \u00B7 '}
{item.meta}
</div>
</div>
</button>
);
}

// ─── Main component ────────────────────────────────────────────────────────

export function AttentionFeed() {
const { items, tier1Count, loading } = useAttentionFeed();
const navigate = useNavigate();

const hasUrgent = tier1Count > 0;
const [manualOpen, setManualOpen] = useState<boolean | null>(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 (
<div className="attention-section">
<button className="overview-header" onClick={toggleOpen}>
<span className="overview-header-title">What&apos;s Next</span>
<span className="overview-header-summary">
{loading ? 'loading...' : summaryParts.join(' \u00B7 ') || 'all clear'}
</span>
{hasUrgent && <span className="overview-badge">{tier1Count}</span>}
<span className={`overview-chevron${isOpen ? ' overview-chevron--open' : ''}`}>
&rsaquo;
</span>
</button>
{isOpen && (
<div className="attention-cards">
{!loading && items.length === 0 && (
<div className="attention-empty">Nothing needs your attention right now.</div>
)}
{items.map((item) => (
<AttentionCard key={item.id} item={item} onTap={handleTap} />
))}
</div>
)}
</div>
);
}
39 changes: 39 additions & 0 deletions frontend/src/components/TaskNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,45 @@
failed: 'pending',
};

const T1_STATUSES: TaskStatus[] = ['pending_review', 'blocked', 'failed'];

Check failure on line 41 in frontend/src/components/TaskNode.tsx

View workflow job for this annotation

GitHub Actions / ci

'T1_STATUSES' is assigned a value but never used

function statusMeta(task: Task): string {

Check failure on line 43 in frontend/src/components/TaskNode.tsx

View workflow job for this annotation

GitHub Actions / ci

'statusMeta' is defined but never used
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 {

Check failure on line 70 in frontend/src/components/TaskNode.tsx

View workflow job for this annotation

GitHub Actions / ci

'doneOpacity' is defined but never used
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.

🔵 style: doneOpacity calls Date.now() during render, making its output non-deterministic. The opacity won't update as time passes unless something else triggers a re-render. If the fade effect should be live, a timer-based approach is needed; otherwise this is fine as a render-time snapshot — but the docstring implies continuous fading.

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: doneOpacity and formatElapsed call Date.now() during render but there's no timer to trigger re-renders, so the opacity and elapsed label become stale until the next external re-render. This is acceptable if the task list refreshes frequently via SSE, but if not, the fade effect won't animate smoothly. [fixable]

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;
Expand Down
100 changes: 72 additions & 28 deletions frontend/src/components/TodoCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,38 @@
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({
Expand All @@ -42,7 +69,7 @@

useEffect(() => {
return () => {
timers.current.forEach(clearTimeout);

Check warning on line 72 in frontend/src/components/TodoCard.tsx

View workflow job for this annotation

GitHub Actions / ci

The ref value 'timers.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'timers.current' to a variable inside the effect, and use that variable in the cleanup function
};
}, []);

Expand Down Expand Up @@ -102,9 +129,12 @@

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 (
<div className={`todo-card-tree-node ${depth > 0 ? 'todo-card-tree-node--child' : ''}`}>
Expand All @@ -119,8 +149,16 @@
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
style={{
borderLeftColor: borderClr,
borderLeftWidth: borderW > 0 ? `${borderW}px` : undefined,
borderLeftStyle: borderW > 0 ? 'solid' : undefined,
}}
>
<div className="todo-card-header">
{/* Line 1: icon + summary + star */}
<div className="todo-card-line1">
<span className="todo-card-icon" style={{ color }}>{icon}</span>
<span className="todo-card-summary">{item.summary}</span>
{hasChildren && (
<button
className="todo-card-expand"
Expand All @@ -132,36 +170,42 @@
{expanded ? '\u25BC' : '\u25B6'}
</button>
)}
<span className="todo-card-status">{statusIcon}</span>
<span className="todo-card-urgency">{urgencyBar(item.urgency)}</span>
{source ? (
<span className="todo-card-source">{sourceIcon(source.type)}</span>
) : (
<span className="todo-card-source todo-card-source--manual">+</span>
)}
<span className="todo-card-age">{ageLabel}</span>
{hasChildren && (
<span className="todo-card-progress">
{item.completedChildCount ?? 0}/{item.childCount ?? children.length}
</span>
)}
<button
className="todo-card-star"
onClick={(e) => {
e.stopPropagation();
onStar(item.id);
}}
>
{item.starred ? '' : ''}
{item.starred ? '\u2B50' : '\u2606'}
</button>
</div>
<div className="todo-card-summary">{item.summary}</div>
{source && (
<div className="todo-card-meta">
<span className="todo-card-author">{source.author}</span>
</div>
)}
<div className="todo-card-actions">

{/* Line 2: source + meta */}
<div className="todo-card-line2">
{source ? (
<span className="todo-card-source">{sourceIcon(source.type)}</span>
) : (
<span className="todo-card-source todo-card-source--manual">+</span>
)}
{source?.author && (
<><span className="todo-card-author">{source.author}</span>{' \u00B7 '}</>
)}
<span className="todo-card-age">{ageLabel}</span>
{' \u00B7 '}
<span className="todo-card-profile">{item.profile}</span>
{hasChildren && (
<>
{' \u00B7 '}
<span className="todo-card-progress">
{item.completedChildCount ?? 0}/{item.childCount ?? children.length}
</span>
</>
)}
</div>

{/* Line 3: actions */}
<div className="todo-card-line3">
<button
className="todo-card-add-child"
onClick={(e) => {
Expand Down
Loading
Loading