feat(ui): redesign updates center into action inbox

- replace release-note-heavy layout with task-first action inbox

- add persistent local notice progress states (new/seen/done/dismissed)

- introduce actionable notice metadata and related integration mapping

- keep full-page shell fixed with internal scrolling only
This commit is contained in:
Tam Nhu Tran
2026-02-25 20:06:02 +07:00
parent 1698eadc94
commit 78eac7d500
7 changed files with 574 additions and 302 deletions
@@ -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 (
<Badge
variant="outline"
className={cn('gap-1.5 border text-[10px] font-medium', meta.className, className)}
>
{meta.showDot && <span className="h-1.5 w-1.5 rounded-full bg-current" />}
{meta.label}
</Badge>
);
}
@@ -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 (
<div className="flex h-full items-center justify-center rounded-lg border border-dashed bg-muted/20 p-6">
<p className="text-sm text-muted-foreground">No updates available.</p>
</div>
);
}
return (
<div className="flex-1 min-w-0 bg-background grid grid-rows-[auto_minmax(0,1fr)] overflow-hidden">
<div className="border-b bg-background px-4 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<h2 className="text-base font-semibold leading-tight">{notice.title}</h2>
<p className="text-sm text-muted-foreground">{notice.summary}</p>
</div>
<div className="flex items-center gap-2">
{progress && <NoticeProgressBadge state={progress} />}
<SupportStatusBadge status={notice.status} />
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<CalendarClock className="h-3.5 w-3.5" />
<span>Published {formatCatalogDate(notice.publishedAt)}</span>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" onClick={() => onUpdateProgress('done')}>
<CheckCircle2 className="h-4 w-4" />
Mark done
</Button>
<Button size="sm" variant="outline" onClick={() => onUpdateProgress('dismissed')}>
<EyeOff className="h-4 w-4" />
Dismiss
</Button>
<Button size="sm" variant="ghost" onClick={() => onUpdateProgress('new')}>
<RotateCcw className="h-4 w-4" />
Reopen
</Button>
</div>
</div>
<div className="min-h-0 p-4">
<div className="grid h-full gap-4 xl:grid-cols-[minmax(0,1.5fr)_minmax(320px,1fr)] overflow-hidden">
<Card className="h-full overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
<CardTitle className="text-base">Do Next</CardTitle>
</div>
<CardDescription>{notice.primaryAction}</CardDescription>
</CardHeader>
<CardContent className="min-h-0">
<ScrollArea className="h-full pr-2">
<div className="space-y-3">
{notice.actions.map((action) => (
<UpdatesNoticeActionRow key={`${notice.id}-${action.id}`} action={action} />
))}
</div>
</ScrollArea>
</CardContent>
</Card>
<div className="grid h-full grid-rows-[minmax(0,1fr)_minmax(0,1fr)] gap-4 overflow-hidden">
<Card className="overflow-hidden">
<CardHeader className="pb-3">
<CardTitle className="text-base">Impacted Integrations</CardTitle>
<CardDescription>Related areas based on update scope and routing.</CardDescription>
</CardHeader>
<CardContent className="min-h-0">
<ScrollArea className="h-full pr-2">
<div className="space-y-2">
{relatedEntries.map((entry) => (
<div key={entry.id} className="rounded-md border bg-muted/20 p-2.5">
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 truncate text-sm font-medium">{entry.name}</p>
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
{SUPPORT_SCOPE_LABELS[entry.scope]}
</Badge>
</div>
<div className="mt-2 flex items-center gap-2">
{entry.routes[0] && (
<Button size="sm" variant="outline" asChild>
<Link to={entry.routes[0].path}>{entry.routes[0].label}</Link>
</Button>
)}
{entry.commands[0] && (
<div className="ml-auto flex min-w-0 items-center gap-1.5">
<code className="truncate rounded bg-background px-1.5 py-0.5 text-[11px]">
{entry.commands[0]}
</code>
<CopyButton value={entry.commands[0]} />
</div>
)}
</div>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
<Card className="overflow-hidden">
<CardHeader className="pb-3">
<CardTitle className="text-base">Why It Matters</CardTitle>
<CardDescription>
Short context only, no wall-of-text release notes.
</CardDescription>
</CardHeader>
<CardContent className="min-h-0">
<ScrollArea className="h-full pr-2">
<ul className="space-y-2 text-sm text-muted-foreground">
{notice.highlights.map((highlight) => (
<li key={`${notice.id}-${highlight}`}>- {highlight}</li>
))}
</ul>
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}
@@ -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 (
<button
type="button"
onClick={onSelect}
className={cn(
'w-full rounded-lg border px-3 py-3 text-left transition-colors',
selected
? 'border-primary/30 bg-primary/10'
: 'border-transparent bg-background/40 hover:border-border hover:bg-muted/70'
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<p className="truncate text-sm font-medium">{notice.title}</p>
<p className="line-clamp-2 text-xs text-muted-foreground">{notice.primaryAction}</p>
</div>
<ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<span className="text-[11px] text-muted-foreground">
{formatCatalogDate(notice.publishedAt)}
</span>
<NoticeProgressBadge state={progress} />
</div>
</button>
);
}
@@ -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 (
<div className="rounded-md border bg-muted/20 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-0.5">
<p className="text-sm font-medium">{action.label}</p>
<p className="text-xs text-muted-foreground">{action.description}</p>
</div>
{isRouteAction && (
<Button size="sm" asChild>
<Link to={action.path}>
Open
<ArrowUpRight className="h-3.5 w-3.5" />
</Link>
</Button>
)}
</div>
{isCommandAction && (
<div className="mt-2 flex items-center gap-2 rounded-md border bg-background px-2 py-1.5">
<Terminal className="h-3.5 w-3.5 text-muted-foreground" />
<code className="min-w-0 flex-1 truncate text-[11px]">{action.command}</code>
<CopyButton value={action.command} />
</div>
)}
</div>
);
}
+75
View File
@@ -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;
+63
View File
@@ -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<string, NoticeProgressState>;
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<SupportNotice, 'id' | 'status'>,
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));
}
+146 -302
View File
@@ -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 (
<button
type="button"
onClick={onSelect}
className={cn(
'w-full rounded-lg border px-3 py-2 text-left transition-colors',
isSelected
? 'border-primary/20 bg-primary/10'
: 'border-transparent hover:border-border hover:bg-muted/70'
)}
>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{notice.title}</p>
<p className="mt-0.5 text-[11px] text-muted-foreground">
{formatCatalogDate(notice.publishedAt)}
</p>
</div>
<ChevronRight className={cn('mt-0.5 h-4 w-4 shrink-0 text-muted-foreground')} />
</div>
</button>
);
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<ScopeFilter>('all');
const notices = useMemo(
() => [...SUPPORT_NOTICES].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)),
[]
);
const [viewMode, setViewMode] = useState<NoticeViewMode>('inbox');
const [query, setQuery] = useState('');
const [selectedNoticeId, setSelectedNoticeId] = useState<string | null>(
SUPPORT_NOTICES[0]?.id ?? null
);
const [progressMap, setProgressMap] = useState<NoticeProgressMap>(() => readNoticeProgressMap());
const [selectedNoticeId, setSelectedNoticeId] = useState<string | null>(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 (
<div className="h-[calc(100vh-100px)] flex overflow-hidden">
<div className="w-80 border-r bg-muted/30 flex flex-col overflow-hidden">
<div className="p-4 border-b bg-background">
<div className="mb-1 flex items-center gap-2">
<Megaphone className="w-5 h-5 text-primary" />
<h1 className="font-semibold">Updates</h1>
<div className="p-4 border-b bg-background space-y-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Megaphone className="h-5 w-5 text-primary" />
<h1 className="font-semibold">Updates Inbox</h1>
</div>
<p className="text-xs text-muted-foreground">
Focus on actions, then mark updates done or dismissed.
</p>
</div>
<p className="mb-3 text-xs text-muted-foreground">
Product announcements and release notes.
</p>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="rounded-md border bg-background px-2 py-1.5">
<p className="text-muted-foreground">Needs Action</p>
<p className="text-base font-semibold">{pendingCount}</p>
</div>
<div className="rounded-md border bg-background px-2 py-1.5">
<p className="text-muted-foreground">Done</p>
<p className="text-base font-semibold">{doneCount}</p>
</div>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search updates or integrations"
className="pl-8 h-9"
placeholder="Search actions or commands"
className="h-9 pl-8"
/>
</div>
<div className="flex flex-wrap gap-1.5">
{NOTICE_VIEW_MODES.map((mode) => (
<Button
key={mode.id}
size="sm"
variant={viewMode === mode.id ? 'default' : 'outline'}
onClick={() => setViewMode(mode.id)}
>
{mode.label}
</Button>
))}
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{SUPPORT_NOTICES.map((notice) => (
<NoticeListItem
key={notice.id}
notice={notice}
isSelected={selectedNotice?.id === notice.id}
onSelect={() => setSelectedNoticeId(notice.id)}
/>
))}
<div className="space-y-2 p-2">
{visibleNotices.length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-xs text-muted-foreground">
No notices match this view.
</div>
) : (
visibleNotices.map((notice) => (
<UpdatesInboxItem
key={notice.id}
notice={notice}
progress={getNoticeProgress(notice, progressMap)}
selected={selectedNotice?.id === notice.id}
onSelect={() => handleSelectNotice(notice)}
/>
))
)}
</div>
</ScrollArea>
<div className="border-t bg-background p-3 text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>
{SUPPORT_NOTICES.length} notice{SUPPORT_NOTICES.length !== 1 ? 's' : ''}
</span>
<span>
{filteredEntries.length} result{filteredEntries.length !== 1 ? 's' : ''}
</span>
</div>
</div>
</div>
<div className="flex-1 min-w-0 bg-background grid grid-rows-[auto_minmax(0,1fr)] overflow-hidden">
{selectedNotice && (
<div className="border-b bg-background px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-base font-semibold leading-tight">{selectedNotice.title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{selectedNotice.summary}</p>
</div>
<SupportStatusBadge status={selectedNotice.status} />
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<CalendarClock className="h-3.5 w-3.5" />
<span>Published {formatCatalogDate(selectedNotice.publishedAt)}</span>
</div>
</div>
)}
<div className="min-h-0 p-4">
<div className="grid h-full gap-4 xl:grid-cols-[minmax(0,1.7fr)_minmax(320px,1fr)] overflow-hidden">
<Card className="h-full gap-3 overflow-hidden">
<CardHeader className="gap-2">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
<CardTitle className="text-base">Release Details</CardTitle>
</div>
<CardDescription>
Changelog-first view with impacted integrations and quick commands.
</CardDescription>
</CardHeader>
<CardContent className="flex-1 min-h-0">
<div className="h-full flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Filter className="h-3.5 w-3.5" />
Filter:
</span>
{SCOPE_FILTERS.map((filter) => (
<Button
key={filter.id}
size="sm"
variant={scope === filter.id ? 'default' : 'outline'}
onClick={() => setScope(filter.id)}
>
{filter.label}
</Button>
))}
<span className="ml-auto text-xs text-muted-foreground">
{filteredEntries.length} integration{filteredEntries.length !== 1 ? 's' : ''}
</span>
</div>
<ScrollArea className="flex-1 min-h-0 pr-2">
<div className="space-y-4 pb-1">
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
What Changed
</h3>
<ul className="space-y-1 text-sm text-muted-foreground">
{selectedNotice?.highlights.map((highlight) => (
<li key={`${selectedNotice.id}-${highlight}`}>- {highlight}</li>
))}
</ul>
</section>
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Dashboard Entry Points
</h3>
<div className="flex flex-wrap gap-2">
{selectedNotice?.routes.map((route) => (
<Link
key={`${selectedNotice.id}-${route.path}`}
to={route.path}
className="rounded-md border px-2 py-1 text-xs hover:bg-muted"
>
{route.label}
</Link>
))}
</div>
</section>
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Quick Commands
</h3>
<div className="grid gap-2 lg:grid-cols-2">
{selectedNotice?.commands.map((command) => (
<code
key={`${selectedNotice.id}-${command}`}
className="rounded bg-muted px-2 py-1.5 text-[11px]"
>
{command}
</code>
))}
</div>
</section>
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Impacted Integrations
</h3>
{filteredEntries.length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
No integration entries match your current filter.
</div>
) : (
<div className="space-y-2">
{filteredEntries.map((entry) => (
<div key={entry.id} className="rounded-md border bg-muted/20 p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{entry.name}</p>
<p className="text-xs text-muted-foreground">{entry.summary}</p>
</div>
<SupportStatusBadge
status={entry.status}
className="h-5 px-1.5 text-[10px]"
/>
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<Badge variant="outline" className="text-[10px] h-5 px-1.5">
{SUPPORT_SCOPE_LABELS[entry.scope]}
</Badge>
{entry.routes.map((route) => (
<Link
key={`${entry.id}-${route.path}`}
to={route.path}
className="rounded-md border px-1.5 py-0.5 text-[10px] text-muted-foreground hover:bg-muted"
>
{route.label}
</Link>
))}
</div>
<code className="mt-2 block rounded bg-muted px-2 py-1 text-[11px]">
{entry.commands[0]}
</code>
</div>
))}
</div>
)}
</section>
</div>
</ScrollArea>
</div>
</CardContent>
</Card>
<Card className="h-full gap-3 overflow-hidden">
<CardHeader className="gap-2">
<CardTitle className="text-base">Announcement Timeline</CardTitle>
<CardDescription>Recent notices in chronological order.</CardDescription>
</CardHeader>
<CardContent className="flex-1 min-h-0">
<ScrollArea className="h-full pr-2">
<div className="space-y-2 pb-1">
{SUPPORT_NOTICES.map((notice) => (
<button
key={`timeline-${notice.id}`}
type="button"
onClick={() => setSelectedNoticeId(notice.id)}
className={cn(
'w-full rounded-md border p-3 text-left transition-colors',
selectedNotice?.id === notice.id
? 'border-primary/20 bg-primary/10'
: 'hover:bg-muted/70'
)}
>
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">{notice.title}</p>
<SupportStatusBadge
status={notice.status}
className="h-5 px-1.5 text-[10px]"
/>
</div>
<p className="mt-1 text-xs text-muted-foreground">{notice.summary}</p>
<p className="mt-2 text-[11px] text-muted-foreground">
{formatCatalogDate(notice.publishedAt)}
</p>
</button>
))}
<div className="rounded-md border border-dashed p-3 text-xs text-muted-foreground">
Update source of truth:{' '}
<code className="rounded bg-muted px-1.5 py-0.5 text-[11px]">
ui/src/lib/support-updates-catalog.ts
</code>
</div>
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
</div>
<UpdatesDetailsPanel
notice={selectedNotice}
progress={selectedNotice ? getNoticeProgress(selectedNotice, progressMap) : null}
relatedEntries={selectedNotice ? getSupportEntriesForNotice(selectedNotice) : []}
onUpdateProgress={(nextState) => {
if (!selectedNotice) return;
setProgressMap((previous) => ({ ...previous, [selectedNotice.id]: nextState }));
}}
/>
</div>
);
}