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}
+
+
+
+
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 }));
+ }}
+ />
);
}