diff --git a/ui/src/components/updates/notice-progress-badge.tsx b/ui/src/components/updates/notice-progress-badge.tsx new file mode 100644 index 00000000..5b6c77ec --- /dev/null +++ b/ui/src/components/updates/notice-progress-badge.tsx @@ -0,0 +1,50 @@ +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import { type NoticeProgressState } from '@/lib/updates-notice-state'; + +const NOTICE_PROGRESS_META: Record< + NoticeProgressState, + { label: string; className: string; showDot?: boolean } +> = { + new: { + label: 'Needs Action', + className: + 'border-amber-300/70 bg-amber-100/70 text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-300', + showDot: true, + }, + seen: { + label: 'In Review', + className: + 'border-blue-300/70 bg-blue-100/70 text-blue-800 dark:border-blue-500/40 dark:bg-blue-500/15 dark:text-blue-300', + }, + done: { + label: 'Done', + className: + 'border-emerald-300/70 bg-emerald-100/70 text-emerald-800 dark:border-emerald-500/40 dark:bg-emerald-500/15 dark:text-emerald-300', + }, + dismissed: { + label: 'Dismissed', + className: + 'border-muted-foreground/20 bg-muted text-muted-foreground dark:border-muted-foreground/30', + }, +}; + +export function NoticeProgressBadge({ + state, + className, +}: { + state: NoticeProgressState; + className?: string; +}) { + const meta = NOTICE_PROGRESS_META[state]; + + return ( + + {meta.showDot && } + {meta.label} + + ); +} diff --git a/ui/src/components/updates/updates-details-panel.tsx b/ui/src/components/updates/updates-details-panel.tsx new file mode 100644 index 00000000..8a7c107f --- /dev/null +++ b/ui/src/components/updates/updates-details-panel.tsx @@ -0,0 +1,157 @@ +import { Link } from 'react-router-dom'; +import { CalendarClock, CheckCircle2, EyeOff, RotateCcw, Sparkles } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { CopyButton } from '@/components/ui/copy-button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { SupportStatusBadge } from '@/components/updates/support-status-badge'; +import { NoticeProgressBadge } from '@/components/updates/notice-progress-badge'; +import { UpdatesNoticeActionRow } from '@/components/updates/updates-notice-action-row'; +import { + SUPPORT_SCOPE_LABELS, + formatCatalogDate, + type CliSupportEntry, + type SupportNotice, +} from '@/lib/support-updates-catalog'; +import { type NoticeProgressState } from '@/lib/updates-notice-state'; + +type UpdatableNoticeProgress = 'new' | 'seen' | 'done' | 'dismissed'; + +export function UpdatesDetailsPanel({ + notice, + progress, + relatedEntries, + onUpdateProgress, +}: { + notice: SupportNotice | null; + progress: NoticeProgressState | null; + relatedEntries: CliSupportEntry[]; + onUpdateProgress: (nextState: UpdatableNoticeProgress) => void; +}) { + if (!notice) { + return ( +
+

No updates available.

+
+ ); + } + + return ( +
+
+
+
+

{notice.title}

+

{notice.summary}

+
+
+ {progress && } + +
+
+ +
+ + Published {formatCatalogDate(notice.publishedAt)} +
+ +
+ + + +
+
+ +
+
+ + +
+ + Do Next +
+ {notice.primaryAction} +
+ + +
+ {notice.actions.map((action) => ( + + ))} +
+
+
+
+ +
+ + + Impacted Integrations + Related areas based on update scope and routing. + + + +
+ {relatedEntries.map((entry) => ( +
+
+

{entry.name}

+ + {SUPPORT_SCOPE_LABELS[entry.scope]} + +
+
+ {entry.routes[0] && ( + + )} + {entry.commands[0] && ( +
+ + {entry.commands[0]} + + +
+ )} +
+
+ ))} +
+
+
+
+ + + + Why It Matters + + Short context only, no wall-of-text release notes. + + + + +
    + {notice.highlights.map((highlight) => ( +
  • - {highlight}
  • + ))} +
+
+
+
+
+
+
+
+ ); +} diff --git a/ui/src/components/updates/updates-inbox-item.tsx b/ui/src/components/updates/updates-inbox-item.tsx new file mode 100644 index 00000000..4b7ea984 --- /dev/null +++ b/ui/src/components/updates/updates-inbox-item.tsx @@ -0,0 +1,45 @@ +import { ChevronRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { formatCatalogDate, type SupportNotice } from '@/lib/support-updates-catalog'; +import { type NoticeProgressState } from '@/lib/updates-notice-state'; +import { NoticeProgressBadge } from './notice-progress-badge'; + +export function UpdatesInboxItem({ + notice, + progress, + selected, + onSelect, +}: { + notice: SupportNotice; + progress: NoticeProgressState; + selected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} diff --git a/ui/src/components/updates/updates-notice-action-row.tsx b/ui/src/components/updates/updates-notice-action-row.tsx new file mode 100644 index 00000000..5bbe1e37 --- /dev/null +++ b/ui/src/components/updates/updates-notice-action-row.tsx @@ -0,0 +1,38 @@ +import { Link } from 'react-router-dom'; +import { ArrowUpRight, Terminal } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { CopyButton } from '@/components/ui/copy-button'; +import { type SupportNoticeAction } from '@/lib/support-updates-catalog'; + +export function UpdatesNoticeActionRow({ action }: { action: SupportNoticeAction }) { + const isRouteAction = action.type === 'route' && action.path; + const isCommandAction = action.type === 'command' && action.command; + + return ( +
+
+
+

{action.label}

+

{action.description}

+
+ + {isRouteAction && ( + + )} +
+ + {isCommandAction && ( +
+ + {action.command} + +
+ )} +
+ ); +} diff --git a/ui/src/lib/support-updates-catalog.ts b/ui/src/lib/support-updates-catalog.ts index 195bc992..d0f2d86f 100644 --- a/ui/src/lib/support-updates-catalog.ts +++ b/ui/src/lib/support-updates-catalog.ts @@ -7,13 +7,26 @@ export interface SupportRouteHint { path: string; } +export interface SupportNoticeAction { + id: string; + label: string; + description: string; + type: 'route' | 'command'; + path?: string; + command?: string; +} + export interface SupportNotice { id: string; title: string; summary: string; + primaryAction: string; publishedAt: string; status: SupportStatus; + scopes: SupportScope[]; + entryIds: string[]; highlights: string[]; + actions: SupportNoticeAction[]; routes: SupportRouteHint[]; commands: string[]; } @@ -47,13 +60,48 @@ export const SUPPORT_NOTICES: SupportNotice[] = [ title: 'Factory Droid support is live', summary: 'API Profiles and CLIProxy variants now support Droid as a first-class execution target.', + primaryAction: 'Set Droid as your default execution target for non-Claude workflows.', publishedAt: '2026-02-25', status: 'new', + scopes: ['target', 'api-profiles', 'cliproxy'], + entryIds: ['droid-target', 'custom-api-profiles', 'codex-cliproxy', 'agy-cliproxy'], highlights: [ 'Set default target to Droid when creating or editing API Profiles.', 'Set default target to Droid for CLIProxy variants, including Codex and Antigravity flows.', 'Use ccsd alias or --target droid for one-off target overrides.', ], + actions: [ + { + id: 'open-api-profiles', + label: 'Set default target in API Profiles', + description: + 'Open API Profiles and set Default Target to Droid for profiles you run often.', + type: 'route', + path: '/providers', + }, + { + id: 'open-cliproxy', + label: 'Set default target in CLIProxy variants', + description: + 'Open CLIProxy variants and set target to Droid for Codex/Antigravity or custom variants.', + type: 'route', + path: '/cliproxy', + }, + { + id: 'copy-ccsd-command', + label: 'Run once with Droid alias', + description: 'Use ccsd to force Droid target with your current profile.', + type: 'command', + command: 'ccsd glm', + }, + { + id: 'copy-target-override', + label: 'Run once with --target override', + description: 'Keep your default profile but force Droid for a single command.', + type: 'command', + command: 'ccs codex --target droid "your prompt"', + }, + ], routes: [ { label: 'API Profiles', path: '/providers' }, { label: 'CLIProxy', path: '/cliproxy' }, @@ -69,13 +117,32 @@ export const SUPPORT_NOTICES: SupportNotice[] = [ title: 'Updates Center added to dashboard navigation', summary: 'CCS now has a dedicated updates route so support announcements are visible and reusable.', + primaryAction: 'Use this page as your action inbox, then close updates when done.', publishedAt: '2026-02-25', status: 'new', + scopes: ['target', 'cliproxy', 'api-profiles', 'websearch'], + entryIds: ['droid-target', 'codex-cliproxy', 'custom-api-profiles', 'opencode-websearch'], highlights: [ 'Single data source powers Home spotlight and Updates Center page.', 'New support entries can be added without touching multiple pages.', 'Catalog includes targets, CLIProxy providers, and WebSearch integrations.', ], + actions: [ + { + id: 'open-updates-page', + label: 'Review new support updates', + description: 'Work through pending notices and mark them done when configured.', + type: 'route', + path: '/updates', + }, + { + id: 'copy-open-dashboard', + label: 'Open dashboard from terminal', + description: 'Re-open config dashboard anytime from CLI.', + type: 'command', + command: 'ccs config', + }, + ], routes: [{ label: 'Updates Center', path: '/updates' }], commands: ['ccs config'], }, @@ -199,6 +266,14 @@ export const CLI_SUPPORT_ENTRIES: CliSupportEntry[] = [ }, ]; +const SUPPORT_ENTRY_LOOKUP = new Map(CLI_SUPPORT_ENTRIES.map((entry) => [entry.id, entry])); + +export function getSupportEntriesForNotice(notice: SupportNotice): CliSupportEntry[] { + return notice.entryIds + .map((entryId) => SUPPORT_ENTRY_LOOKUP.get(entryId)) + .filter((entry): entry is CliSupportEntry => Boolean(entry)); +} + export function getLatestSupportNotice(): SupportNotice | null { if (SUPPORT_NOTICES.length === 0) { return null; diff --git a/ui/src/lib/updates-notice-state.ts b/ui/src/lib/updates-notice-state.ts new file mode 100644 index 00000000..66052f37 --- /dev/null +++ b/ui/src/lib/updates-notice-state.ts @@ -0,0 +1,63 @@ +import { type SupportNotice, type SupportStatus } from '@/lib/support-updates-catalog'; + +export type NoticeProgressState = 'new' | 'seen' | 'done' | 'dismissed'; + +export type NoticeProgressMap = Record; + +const NOTICE_PROGRESS_STORAGE_KEY = 'ccs:updates:notice-progress:v1'; + +export function getDefaultNoticeProgress(status: SupportStatus): NoticeProgressState { + return status === 'new' ? 'new' : 'seen'; +} + +export function getNoticeProgress( + notice: Pick, + progressMap: NoticeProgressMap +): NoticeProgressState { + return progressMap[notice.id] ?? getDefaultNoticeProgress(notice.status); +} + +export function isActionableNoticeState(progress: NoticeProgressState): boolean { + return progress !== 'done' && progress !== 'dismissed'; +} + +export function readNoticeProgressMap(): NoticeProgressMap { + if (typeof window === 'undefined') { + return {}; + } + + try { + const rawValue = window.localStorage.getItem(NOTICE_PROGRESS_STORAGE_KEY); + if (!rawValue) { + return {}; + } + + const parsed = JSON.parse(rawValue); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + + const normalized: NoticeProgressMap = {}; + for (const [key, value] of Object.entries(parsed)) { + if (typeof key !== 'string') { + continue; + } + + if (value === 'new' || value === 'seen' || value === 'done' || value === 'dismissed') { + normalized[key] = value; + } + } + + return normalized; + } catch { + return {}; + } +} + +export function writeNoticeProgressMap(progressMap: NoticeProgressMap): void { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.setItem(NOTICE_PROGRESS_STORAGE_KEY, JSON.stringify(progressMap)); +} diff --git a/ui/src/pages/updates.tsx b/ui/src/pages/updates.tsx index ffc85428..2ce8c9d1 100644 --- a/ui/src/pages/updates.tsx +++ b/ui/src/pages/updates.tsx @@ -1,345 +1,189 @@ -import { useMemo, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { CalendarClock, ChevronRight, Filter, Megaphone, Search, Sparkles } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { Megaphone, Search } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { cn } from '@/lib/utils'; -import { SupportStatusBadge } from '@/components/updates/support-status-badge'; +import { UpdatesDetailsPanel } from '@/components/updates/updates-details-panel'; +import { UpdatesInboxItem } from '@/components/updates/updates-inbox-item'; import { - CLI_SUPPORT_ENTRIES, SUPPORT_NOTICES, - SUPPORT_SCOPE_LABELS, - formatCatalogDate, + getSupportEntriesForNotice, type SupportNotice, - type SupportScope, } from '@/lib/support-updates-catalog'; +import { + getNoticeProgress, + isActionableNoticeState, + readNoticeProgressMap, + writeNoticeProgressMap, + type NoticeProgressMap, +} from '@/lib/updates-notice-state'; -type ScopeFilter = 'all' | SupportScope; +type NoticeViewMode = 'inbox' | 'done' | 'all'; -const SCOPE_FILTERS: { id: ScopeFilter; label: string }[] = [ +const NOTICE_VIEW_MODES: { id: NoticeViewMode; label: string }[] = [ + { id: 'inbox', label: 'Action Required' }, + { id: 'done', label: 'Done' }, { id: 'all', label: 'All' }, - { id: 'target', label: SUPPORT_SCOPE_LABELS.target }, - { id: 'cliproxy', label: SUPPORT_SCOPE_LABELS.cliproxy }, - { id: 'api-profiles', label: SUPPORT_SCOPE_LABELS['api-profiles'] }, - { id: 'websearch', label: SUPPORT_SCOPE_LABELS.websearch }, ]; -function NoticeListItem({ - notice, - isSelected, - onSelect, -}: { - notice: SupportNotice; - isSelected: boolean; - onSelect: () => void; -}) { - return ( - - ); +function noticeMatchesQuery(notice: SupportNotice, queryValue: string): boolean { + if (!queryValue) { + return true; + } + + const haystack = [ + notice.title, + notice.summary, + notice.primaryAction, + ...notice.highlights, + ...notice.commands, + ...notice.actions.map( + (action) => `${action.label} ${action.description} ${action.command || ''}` + ), + ...notice.routes.map((route) => route.label), + ] + .join(' ') + .toLowerCase(); + + return haystack.includes(queryValue); } export function UpdatesPage() { - const [scope, setScope] = useState('all'); + const notices = useMemo( + () => [...SUPPORT_NOTICES].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)), + [] + ); + const [viewMode, setViewMode] = useState('inbox'); const [query, setQuery] = useState(''); - const [selectedNoticeId, setSelectedNoticeId] = useState( - SUPPORT_NOTICES[0]?.id ?? null - ); + const [progressMap, setProgressMap] = useState(() => readNoticeProgressMap()); + const [selectedNoticeId, setSelectedNoticeId] = useState(null); - const selectedNotice = useMemo( - () => SUPPORT_NOTICES.find((notice) => notice.id === selectedNoticeId) ?? SUPPORT_NOTICES[0], - [selectedNoticeId] - ); + useEffect(() => { + writeNoticeProgressMap(progressMap); + }, [progressMap]); - const filteredEntries = useMemo(() => { + const visibleNotices = useMemo(() => { const queryValue = query.trim().toLowerCase(); - return CLI_SUPPORT_ENTRIES.filter((entry) => { - if (scope !== 'all' && entry.scope !== scope) { - return false; - } - - if (!queryValue) { - return true; - } - - const haystack = [ - entry.name, - entry.summary, - entry.notes || '', - ...entry.commands, - ...entry.routes.map((route) => route.label), - ] - .join(' ') - .toLowerCase(); - - return haystack.includes(queryValue); + return notices.filter((notice) => { + const progress = getNoticeProgress(notice, progressMap); + const matchesQuery = noticeMatchesQuery(notice, queryValue); + if (!matchesQuery) return false; + if (viewMode === 'done') return progress === 'done'; + if (viewMode === 'inbox') return isActionableNoticeState(progress); + return true; }); - }, [scope, query]); + }, [notices, progressMap, query, viewMode]); + + const selectedNotice = useMemo(() => { + const selectionPool = viewMode === 'all' ? notices : visibleNotices; + return ( + selectionPool.find((notice) => notice.id === selectedNoticeId) ?? selectionPool[0] ?? null + ); + }, [notices, selectedNoticeId, viewMode, visibleNotices]); + + const handleSelectNotice = (notice: SupportNotice) => { + setSelectedNoticeId(notice.id); + setProgressMap((previous) => { + const progress = getNoticeProgress(notice, previous); + if (progress !== 'new') { + return previous; + } + + return { ...previous, [notice.id]: 'seen' }; + }); + }; + + const pendingCount = useMemo( + () => + notices.filter((notice) => isActionableNoticeState(getNoticeProgress(notice, progressMap))) + .length, + [notices, progressMap] + ); + const doneCount = useMemo( + () => notices.filter((notice) => getNoticeProgress(notice, progressMap) === 'done').length, + [notices, progressMap] + ); return (
-
-
- -

Updates

+
+
+
+ +

Updates Inbox

+
+

+ Focus on actions, then mark updates done or dismissed. +

-

- Product announcements and release notes. -

+ +
+
+

Needs Action

+

{pendingCount}

+
+
+

Done

+

{doneCount}

+
+
+
setQuery(event.target.value)} - placeholder="Search updates or integrations" - className="pl-8 h-9" + placeholder="Search actions or commands" + className="h-9 pl-8" />
+ +
+ {NOTICE_VIEW_MODES.map((mode) => ( + + ))} +
-
- {SUPPORT_NOTICES.map((notice) => ( - setSelectedNoticeId(notice.id)} - /> - ))} +
+ {visibleNotices.length === 0 ? ( +
+ No notices match this view. +
+ ) : ( + visibleNotices.map((notice) => ( + handleSelectNotice(notice)} + /> + )) + )}
- -
-
- - {SUPPORT_NOTICES.length} notice{SUPPORT_NOTICES.length !== 1 ? 's' : ''} - - - {filteredEntries.length} result{filteredEntries.length !== 1 ? 's' : ''} - -
-
-
- {selectedNotice && ( -
-
-
-

{selectedNotice.title}

-

{selectedNotice.summary}

-
- -
-
- - Published {formatCatalogDate(selectedNotice.publishedAt)} -
-
- )} - -
-
- - -
- - Release Details -
- - Changelog-first view with impacted integrations and quick commands. - -
- - -
-
- - - Filter: - - {SCOPE_FILTERS.map((filter) => ( - - ))} - - {filteredEntries.length} integration{filteredEntries.length !== 1 ? 's' : ''} - -
- - -
-
-

- What Changed -

-
    - {selectedNotice?.highlights.map((highlight) => ( -
  • - {highlight}
  • - ))} -
-
- -
-

- Dashboard Entry Points -

-
- {selectedNotice?.routes.map((route) => ( - - {route.label} - - ))} -
-
- -
-

- Quick Commands -

-
- {selectedNotice?.commands.map((command) => ( - - {command} - - ))} -
-
- -
-

- Impacted Integrations -

- {filteredEntries.length === 0 ? ( -
- No integration entries match your current filter. -
- ) : ( -
- {filteredEntries.map((entry) => ( -
-
-
-

{entry.name}

-

{entry.summary}

-
- -
-
- - {SUPPORT_SCOPE_LABELS[entry.scope]} - - {entry.routes.map((route) => ( - - {route.label} - - ))} -
- - {entry.commands[0]} - -
- ))} -
- )} -
-
-
-
-
-
- - - - Announcement Timeline - Recent notices in chronological order. - - - -
- {SUPPORT_NOTICES.map((notice) => ( - - ))} - -
- Update source of truth:{' '} - - ui/src/lib/support-updates-catalog.ts - -
-
-
-
-
-
-
-
+ { + if (!selectedNotice) return; + setProgressMap((previous) => ({ ...previous, [selectedNotice.id]: nextState })); + }} + />
); }