-
Notifications
You must be signed in to change notification settings - Fork 0
feat(ui): cc-deck attention feed, ATB + Telos visual redesign #311
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'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' : ''}`}> | ||
| › | ||
| </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> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,6 +38,45 @@ | |
| 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 { | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 unsafe_assumptions: |
||
| 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; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔵 style:
doneOpacitycallsDate.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.