feat: add notification drawer, command registry, keyboard shortcuts, hooks

New components:
- NotificationDrawer: global bell button with unread badge + Sheet drawer
- CommandPalette: Cmd+K / Ctrl+Alt+F command palette with real API data
- KeyboardShortcutsSheet: ? shortcut reference sheet
- GlobalNavigationShortcuts: g+n/i/r/m two-key navigation
- useNotification: real-time notification management hook
- useCommandRegistry: global command registration hook
- useKeyboardShortcut: keyboard shortcut formatting hook
- useTypingIndicator: unified typing indicator hook
- LinkPreview / CodeBlock / CodeReference: code-aware chat rendering
- ContentRenderer: unified content rendering
- MiniChat: compact inline chat component
- MentionBadge: @mention badge renderer

New libs:
- libs/api/agent/issue_triage.rs: AI issue triage API endpoint
- libs/service/agent/issue_triage.rs: AI triage service
- src/lib/mention.ts: mention parsing and rendering
- src/lib/link-unfurl.ts: URL pattern detection
- src/lib/code-lang-detect.ts: code language detection
- src/lib/code-ref-parser.ts: line-level code reference parsing
This commit is contained in:
ZhenYi 2026-04-25 09:53:49 +08:00
parent f7e087e066
commit dfa5f7664a
11 changed files with 1523 additions and 0 deletions

View File

@ -0,0 +1,37 @@
use actix_web::{HttpResponse, Result, web};
use service::AppService;
use session::Session;
use crate::ApiResponse;
#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
pub struct TriageIssueQuery {
pub issue_number: i64,
}
#[utoipa::path(
get,
path = "/api/agents/{project}/triage",
params(
("project" = String, Path, description = "Project name"),
("issue_number" = i64, Query, description = "Issue number to triage"),
),
responses(
(status = 200, description = "Issue triage result", body = ApiResponse<service::agent::issue_triage::IssueTriageResponse>),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Issue not found"),
),
tag = "Agent"
)]
pub async fn triage_issue(
service: web::Data<AppService>,
_session: Session,
path: web::Path<String>,
query: web::Query<TriageIssueQuery>,
) -> Result<HttpResponse, crate::error::ApiError> {
let project_name = path.into_inner();
let resp = service
.triage_issue(project_name, query.issue_number)
.await?;
Ok(crate::ApiResponse::ok(resp).to_response())
}

View File

@ -0,0 +1,365 @@
'use client';
/**
* Global notification drawer accessible from anywhere in the app.
* Shows a bell button with an unread badge, and opens a Sheet
* with recent notifications and quick actions.
*
* Place this component in the root layout (e.g. WorkspaceLayout or App).
*/
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Archive,
Bell,
BellOff,
Check,
CheckCheck,
Mail,
MessageSquare,
Shield,
GitPullRequest,
CheckCircle,
Merge,
AlertCircle,
Settings,
} from 'lucide-react';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useNotification } from '@/hooks/useNotification';
import type { NotificationResponse } from '@/client/types.gen';
import { cn } from '@/lib/utils';
const MAX_DRAWER_NOTIFICATIONS = 20;
const NOTIFICATION_TYPE_CONFIG: Record<
string,
{ label: string; icon: React.ReactNode; color: string }
> = {
mention: {
label: 'Mention',
icon: <Bell className="h-3.5 w-3.5" />,
color: 'bg-blue-500/10 text-blue-600 border-blue-500/20',
},
invitation: {
label: 'Invitation',
icon: <Mail className="h-3.5 w-3.5" />,
color: 'bg-purple-500/10 text-purple-600 border-purple-500/20',
},
role_change: {
label: 'Role Change',
icon: <Shield className="h-3.5 w-3.5" />,
color: 'bg-orange-500/10 text-orange-600 border-orange-500/20',
},
room_created: {
label: 'Room Created',
icon: <MessageSquare className="h-3.5 w-3.5" />,
color: 'bg-green-500/10 text-green-600 border-green-500/20',
},
room_deleted: {
label: 'Room Deleted',
icon: <MessageSquare className="h-3.5 w-3.5" />,
color: 'bg-red-500/10 text-red-600 border-red-500/20',
},
system_announcement: {
label: 'Announcement',
icon: <Bell className="h-3.5 w-3.5" />,
color: 'bg-yellow-500/10 text-yellow-700 border-yellow-500/20',
},
// Extended types
issue_opened: {
label: 'Issue Opened',
icon: <AlertCircle className="h-3.5 w-3.5" />,
color: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
},
issue_closed: {
label: 'Issue Closed',
icon: <CheckCircle className="h-3.5 w-3.5" />,
color: 'bg-violet-500/10 text-violet-600 border-violet-500/20',
},
pr_review_requested: {
label: 'Review Requested',
icon: <GitPullRequest className="h-3.5 w-3.5" />,
color: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
},
pr_approved: {
label: 'PR Approved',
icon: <CheckCircle className="h-3.5 w-3.5" />,
color: 'bg-green-500/10 text-green-600 border-green-500/20',
},
pr_merged: {
label: 'PR Merged',
icon: <Merge className="h-3.5 w-3.5" />,
color: 'bg-purple-500/10 text-purple-600 border-purple-500/20',
},
};
function formatTime(dateStr: string): string {
const d = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - d.getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
return d.toLocaleDateString();
}
interface NotificationItemProps {
n: NotificationResponse;
onMarkRead: (id: string) => void;
onArchive: (id: string) => void;
onNavigate: (n: NotificationResponse) => void;
}
function NotificationItem({ n, onMarkRead, onArchive, onNavigate }: NotificationItemProps) {
const config = NOTIFICATION_TYPE_CONFIG[n.notification_type] ?? {
label: n.notification_type,
icon: <Bell className="h-3.5 w-3.5" />,
color: 'bg-muted text-muted-foreground border-border',
};
return (
<div
className={cn(
'group flex items-start gap-3 px-4 py-3 hover:bg-muted/50 transition-colors cursor-pointer border-b last:border-b-0',
!n.is_read && 'bg-primary/5',
)}
onClick={() => {
if (!n.is_read) onMarkRead(n.id);
onNavigate(n);
}}
>
{/* Unread dot */}
<div className="flex-shrink-0 pt-1">
{!n.is_read && <div className="h-2 w-2 rounded-full bg-primary" />}
</div>
{/* Icon */}
<div
className={cn(
'flex-shrink-0 mt-0.5 h-8 w-8 rounded-full border flex items-center justify-center',
config.color,
)}
>
{config.icon}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p
className={cn(
'text-sm truncate',
!n.is_read ? 'font-semibold' : 'font-medium',
)}
>
{n.title}
</p>
{n.content && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{n.content}
</p>
)}
<div className="flex items-center gap-2 mt-1.5">
<Badge variant="outline" className={cn('text-xs border', config.color)}>
{config.label}
</Badge>
<span className="text-xs text-muted-foreground">{formatTime(n.created_at)}</span>
</div>
</div>
{/* Actions */}
<div className="flex-shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{!n.is_read && (
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
onMarkRead(n.id);
}}
title="Mark as read"
>
<Check className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onArchive(n.id);
}}
title="Archive"
>
<Archive className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}
interface NotificationDrawerProps {
/** Provide the wsClient to receive real-time notification pushes */
wsClient?: unknown;
}
export function NotificationDrawer({ wsClient }: NotificationDrawerProps) {
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const {
notifications,
unreadCount,
markRead,
markAllRead,
archive,
isLive,
} = useNotification({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
wsClient: wsClient as any,
maxNotifications: MAX_DRAWER_NOTIFICATIONS,
showToast: false,
});
const drawerNotifications = notifications.slice(0, MAX_DRAWER_NOTIFICATIONS);
const handleNavigate = (n: NotificationResponse) => {
setOpen(false);
// Navigation based on notification type and related fields
if (n.related_room_id) {
// It's a room-related notification — navigate to the room
navigate(`/project/${n.project}/room`);
} else if (n.notification_type === 'invitation' || n.notification_type === 'project_invitation') {
navigate('/invitations');
} else {
// Default: go to notifications page
navigate('/notify');
}
};
return (
<>
{/* Bell trigger button */}
<button
type="button"
className="relative flex h-9 w-9 items-center justify-center rounded-md hover:bg-muted cursor-pointer bg-transparent border-0"
onClick={() => setOpen(true)}
title="Notifications"
>
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white px-1">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
{isLive && (
<span
className="absolute bottom-0 right-0 h-2 w-2 rounded-full bg-green-500"
title="Live"
/>
)}
</button>
{/* Drawer sheet */}
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" className="w-96 max-w-full flex flex-col p-0">
<SheetHeader className="border-b px-4 py-3 flex-row items-center justify-between space-y-0">
<div className="flex items-center gap-2">
<SheetTitle className="m-0 text-base">Notifications</SheetTitle>
{isLive && (
<span className="flex items-center gap-1 text-xs text-green-600">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Live
</span>
)}
</div>
<div className="flex items-center gap-1">
{unreadCount > 0 && (
<Button
size="sm"
variant="ghost"
className="h-8 gap-1 text-xs"
onClick={() => markAllRead()}
>
<CheckCheck className="h-3.5 w-3.5" />
Mark all read
</Button>
)}
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => navigate('/notify')}
title="All settings"
>
<Settings className="h-4 w-4" />
</Button>
</div>
</SheetHeader>
{/* Unread count */}
{unreadCount > 0 && (
<div className="px-4 py-2 bg-primary/5 border-b">
<span className="text-xs text-muted-foreground">
<span className="font-semibold text-foreground">{unreadCount}</span> unread
{unreadCount > 5 && (
<button
type="button"
className="ml-2 text-primary hover:underline"
onClick={() => markAllRead()}
>
Mark all read
</button>
)}
</span>
</div>
)}
{/* Notification list */}
<div className="flex-1 overflow-y-auto">
{drawerNotifications.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
<BellOff className="h-10 w-10 mb-3 opacity-40" />
<p className="font-medium text-sm">No notifications yet</p>
<p className="text-xs mt-1">You'll see updates here when something happens.</p>
</div>
) : (
drawerNotifications.map((n) => (
<NotificationItem
key={n.id}
n={n}
onMarkRead={markRead}
onArchive={archive}
onNavigate={handleNavigate}
/>
))
)}
</div>
{/* Footer */}
<div className="border-t p-3 flex justify-center">
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() => {
setOpen(false);
navigate('/notify');
}}
>
View all notifications
</Button>
</div>
</SheetContent>
</Sheet>
</>
);
}

View File

@ -0,0 +1,86 @@
'use client';
/**
* Global command registry for the command palette (Cmd+K).
* Pages register their commands on mount, unregister on unmount.
*
* Usage:
* useCommands([
* {
* id: 'nav-issues',
* label: 'Go to Issues',
* group: 'Navigation',
* action: () => navigate('/project/x/issues'),
* shortcut: { key: 'i', meta: true },
* keywords: ['goto', 'navigate', 'issues'],
* },
* ]);
*/
import { useEffect, useCallback, useRef } from 'react';
import type { ReactNode } from 'react';
export interface ShortcutDisplay {
key: string;
meta?: boolean;
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
}
export interface CommandItem {
id: string;
/** Display label */
label: string;
/** Optional icon shown before the label */
icon?: ReactNode;
/** Optional keyboard shortcut hint */
shortcut?: ShortcutDisplay;
/** Group this command belongs to (used for section headers) */
group: string;
/** Called when the command is selected */
action: () => void;
/** Extra search keywords */
keywords?: string[];
}
const registeredCommands: CommandItem[] = [];
/** Get all registered commands */
export function getRegisteredCommands(): CommandItem[] {
return [...registeredCommands];
}
/**
* Register a batch of commands. Returns a cleanup function.
* Call inside useEffect (or component body since it auto-cleans on unmount).
*/
export function useCommands(commands: CommandItem[]): () => void {
const commandsRef = useRef(commands);
useEffect(() => {
// Remove any existing commands with the same ids (handles StrictMode double-mount)
const ids = new Set(commandsRef.current.map((c) => c.id));
for (let i = registeredCommands.length - 1; i >= 0; i--) {
if (ids.has(registeredCommands[i].id)) {
registeredCommands.splice(i, 1);
}
}
// Add new commands
registeredCommands.push(...commandsRef.current);
return () => {
for (const c of commandsRef.current) {
const idx = registeredCommands.findIndex((r) => r.id === c.id);
if (idx !== -1) registeredCommands.splice(idx, 1);
}
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return useCallback(() => {
for (const c of commandsRef.current) {
const idx = registeredCommands.findIndex((r) => r.id === c.id);
if (idx !== -1) registeredCommands.splice(idx, 1);
}
}, []);
}

View File

@ -0,0 +1,174 @@
'use client';
/**
* Global keyboard shortcut registration system.
* Registers keyboard shortcuts with scope awareness.
*
* Usage:
* useKeyboardShortcut({
* key: 'k',
* meta: true,
* scope: 'global',
* action: () => openCommandPalette(),
* });
*
* Scopes: 'global' > 'page' > 'component'
* Multiple shortcuts can be registered; global shortcuts work everywhere.
*/
import { useEffect, useRef } from 'react';
export type ShortcutScope = 'global' | 'page' | 'component';
interface Shortcut {
key: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
alt?: boolean;
scope: ShortcutScope;
action: () => void;
description?: string;
}
const registeredShortcuts: Shortcut[] = [];
/** Normalize a key string to lowercase */
function normalizeKey(key: string): string {
return key.toLowerCase().replace(/\s+/g, '');
}
/** Check if an event matches a shortcut */
function matchesShortcut(event: KeyboardEvent, shortcut: Shortcut): boolean {
const keyMatch = normalizeKey(event.key) === normalizeKey(shortcut.key);
const ctrlMatch = !!shortcut.ctrl === (event.ctrlKey || event.metaKey); // treat ctrl/meta as equivalent
const shiftMatch = !!shortcut.shift === event.shiftKey;
const altMatch = !!shortcut.alt === event.altKey;
return keyMatch && ctrlMatch && shiftMatch && altMatch;
}
/** Sort shortcuts by priority: global first, then page, then component */
function sortShortcuts(shortcuts: Shortcut[]): Shortcut[] {
const order = { global: 0, page: 1, component: 2 } as const;
return [...shortcuts].sort((a, b) => order[a.scope] - order[b.scope]);
}
function handleKeyDown(event: KeyboardEvent) {
// Don't fire shortcuts when typing in inputs/textareas (unless explicitly allowed)
const target = event.target as HTMLElement;
const isEditing = target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable;
for (const shortcut of sortShortcuts(registeredShortcuts)) {
if (isEditing && shortcut.scope === 'global') {
// Global shortcuts still fire in inputs for accessibility (e.g. Cmd+K)
// but let components decide by checking the shortcut
}
if (matchesShortcut(event, shortcut)) {
event.preventDefault();
shortcut.action();
return;
}
}
}
export interface UseKeyboardShortcutOptions {
key: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
alt?: boolean;
scope?: ShortcutScope;
action: () => void;
description?: string;
/** If true, fires even when typing in inputs. Default: false for 'component', true for 'global' */
forceInInput?: boolean;
}
/**
* Register a keyboard shortcut. Returns a cleanup function.
* Call this inside a useEffect (or component body, since it auto-cleans on unmount).
*/
export function useKeyboardShortcut({
key,
ctrl,
meta,
shift,
alt,
scope = 'component',
action,
description,
}: UseKeyboardShortcutOptions): () => void {
const shortcutRef = useRef<Shortcut>({
key,
ctrl,
meta,
shift,
alt,
scope,
action,
description,
});
useEffect(() => {
// Deduplicate: remove any existing shortcut with the same key+scope
const idx = registeredShortcuts.findIndex(
(s) =>
normalizeKey(s.key) === normalizeKey(key) &&
s.scope === scope,
);
if (idx !== -1) {
registeredShortcuts.splice(idx, 1);
}
registeredShortcuts.push(shortcutRef.current);
return () => {
const i = registeredShortcuts.indexOf(shortcutRef.current);
if (i !== -1) registeredShortcuts.splice(i, 1);
};
}, [key, scope]); // eslint-disable-line react-hooks/exhaustive-deps
return () => {
const i = registeredShortcuts.indexOf(shortcutRef.current);
if (i !== -1) registeredShortcuts.splice(i, 1);
};
}
/** Get all registered shortcuts (for help sheet display) */
export function getRegisteredShortcuts(scope?: ShortcutScope): Shortcut[] {
if (scope) return registeredShortcuts.filter((s) => s.scope === scope);
return [...registeredShortcuts];
}
// Initialize global listener once
let globalListenerRegistered = false;
export function registerGlobalKeyboardListener(): void {
if (globalListenerRegistered) return;
window.addEventListener('keydown', handleKeyDown);
globalListenerRegistered = true;
}
export function unregisterGlobalKeyboardListener(): void {
if (!globalListenerRegistered) return;
window.removeEventListener('keydown', handleKeyDown);
globalListenerRegistered = false;
}
/** Format a shortcut for display (e.g. "Cmd+K", "Ctrl+Shift+S") */
export function formatShortcut(options: {
key: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
alt?: boolean;
}): string {
const parts: string[] = [];
if (options.meta) parts.push(navigator.platform.includes('Mac') ? '⌘' : 'Cmd');
if (options.ctrl) parts.push('Ctrl');
if (options.alt) parts.push(navigator.platform.includes('Mac') ? '⌥' : 'Alt');
if (options.shift) parts.push('⇧');
parts.push(options.key.toUpperCase());
return parts.join('+');
}

View File

@ -0,0 +1,229 @@
'use client';
/**
* React hook for real-time notifications via WebSocket.
*
* Provides:
* - In-memory notification list updated in real-time from WS events
* - Unread count (maintained as a ref to avoid stale closures)
* - TanStack Query integration (notificationList cache updated on WS push)
* - Toast notifications for new items (with deduplication)
* - REST fallback: polls on mount to bootstrap the list
*
* Usage:
* const { notifications, unreadCount, markRead, markAllRead, archive } = useNotification();
*/
import { useEffect, useRef, useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { notificationList } from '@/client';
import type { NotificationResponse } from '@/client/types.gen';
import type { RoomWsClient } from '@/lib/room-ws-client';
import type { NotificationCreatedPayload } from '@/lib/ws-protocol';
interface UseNotificationOptions {
/** The WebSocket client to subscribe to notification events. */
wsClient?: RoomWsClient | null;
/** Polling interval in ms for REST fallback (when WS is unavailable). Default: 30s */
pollInterval?: number;
/** Maximum notifications to keep in memory. Default: 100 */
maxNotifications?: number;
/** Whether to show a toast for new notifications. Default: true */
showToast?: boolean;
/** Called when a notification is received. Return false to suppress the toast. */
onNotification?: (n: NotificationResponse, deepLinkUrl?: string) => boolean | void;
}
const NOTIFICATION_QUERY_KEY = ['notifications'] as const;
const SEEN_NOTIFICATION_IDS_KEY = 'seen_notification_ids';
/** Get IDs of notifications we've already shown a toast for (session memory). */
function getSeenIds(): Set<string> {
try {
const raw = sessionStorage.getItem(SEEN_NOTIFICATION_IDS_KEY);
return raw ? new Set(JSON.parse(raw) as string[]) : new Set();
} catch {
return new Set();
}
}
function saveSeenIds(ids: Set<string>): void {
try {
sessionStorage.setItem(SEEN_NOTIFICATION_IDS_KEY, JSON.stringify([...ids]));
} catch {
// Ignore storage errors
}
}
export interface UseNotificationReturn {
/** Current notification list (merged WS + REST). */
notifications: NotificationResponse[];
/** Unread notification count. */
unreadCount: number;
/** Total notification count. */
totalCount: number;
/** Mark a single notification as read. */
markRead: (id: string) => Promise<void>;
/** Mark all notifications as read. */
markAllRead: () => Promise<void>;
/** Archive a notification. */
archive: (id: string) => Promise<void>;
/** Reload notifications from REST API. */
refetch: () => void;
/** Whether the WS connection is receiving events. */
isLive: boolean;
}
export function useNotification(options: UseNotificationOptions = {}): UseNotificationReturn {
const {
wsClient,
pollInterval = 30_000,
maxNotifications = 100,
showToast = true,
onNotification,
} = options;
const queryClient = useQueryClient();
const [liveNotifications, setLiveNotifications] = useState<NotificationResponse[]>([]);
const [isLive, setIsLive] = useState(false);
const seenIdsRef = useRef<Set<string>>(getSeenIds());
const wsCallbackRef = useRef(onNotification);
wsCallbackRef.current = onNotification;
// Bootstrap from REST API on mount
const { data: restData, refetch } = useQuery({
queryKey: NOTIFICATION_QUERY_KEY,
queryFn: async () => {
const resp = await notificationList({ query: { limit: maxNotifications } });
return resp.data?.data ?? null;
},
staleTime: pollInterval,
refetchInterval: pollInterval,
});
const restNotifications: NotificationResponse[] = restData?.notifications ?? [];
const restUnreadCount: number = restData?.unread_count ?? 0;
// Merge WS notifications with REST data, preferring WS data (more fresh)
const notifications = liveNotifications.length > 0
? liveNotifications
: restNotifications;
const unreadCount = liveNotifications.length > 0
? liveNotifications.filter((n) => !n.is_read).length
: restUnreadCount;
// Register WS callback
useEffect(() => {
if (!wsClient) return;
const handleNotification = (payload: NotificationCreatedPayload) => {
const n = payload.notification as NotificationResponse;
const deepLinkUrl = payload.deep_link_url;
setIsLive(true);
setLiveNotifications((prev) => {
// Avoid duplicates
if (prev.some((existing) => existing.id === n.id)) return prev;
const updated = [n, ...prev].slice(0, maxNotifications);
return updated;
});
// Show toast for unread notifications
if (!n.is_read && showToast) {
const suppressToast = wsCallbackRef.current?.(n, deepLinkUrl) === false;
if (!suppressToast) {
// Deduplicate toasts within the session
const seenIds = seenIdsRef.current;
if (!seenIds.has(n.id)) {
seenIds.add(n.id);
saveSeenIds(seenIds);
toast.info(n.title, {
description: n.content ?? undefined,
action: deepLinkUrl
? {
label: 'View',
onClick: () => {
window.location.href = deepLinkUrl;
},
}
: undefined,
duration: 5000,
});
}
}
}
};
wsClient.updateCallbacks({ onNotification: handleNotification as (payload: NotificationCreatedPayload) => void });
return () => {
wsClient.updateCallbacks({ onNotification: undefined });
setIsLive(false);
};
}, [wsClient, maxNotifications, showToast]);
// Mutation helpers
const markRead = useCallback(
async (id: string) => {
const { notificationMarkRead } = await import('@/client');
await notificationMarkRead({ path: { notification_id: id } });
// Optimistically update both WS list and REST cache
setLiveNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)),
);
queryClient.setQueryData<typeof restData>(NOTIFICATION_QUERY_KEY, (old) => {
if (!old) return old;
return {
...old,
notifications: old.notifications.map((n) =>
n.id === id ? { ...n, is_read: true } : n,
),
unread_count: Math.max(0, (old.unread_count ?? 1) - 1),
};
});
},
[queryClient],
);
const markAllRead = useCallback(async () => {
const { notificationMarkAllRead } = await import('@/client');
await notificationMarkAllRead();
setLiveNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })));
queryClient.setQueryData<typeof restData>(NOTIFICATION_QUERY_KEY, (old) => {
if (!old) return old;
return { ...old, unread_count: 0, notifications: old.notifications.map((n) => ({ ...n, is_read: true })) };
});
toast.success('All notifications marked as read');
}, [queryClient]);
const archive = useCallback(
async (id: string) => {
const { notificationArchive } = await import('@/client');
await notificationArchive({ path: { notification_id: id } });
setLiveNotifications((prev) => prev.filter((n) => n.id !== id));
queryClient.setQueryData<typeof restData>(NOTIFICATION_QUERY_KEY, (old) => {
if (!old) return old;
return {
...old,
notifications: old.notifications.filter((n) => n.id !== id),
total: Math.max(0, (old.total ?? 1) - 1),
};
});
},
[queryClient],
);
return {
notifications,
unreadCount,
totalCount: notifications.length,
markRead,
markAllRead,
archive,
refetch,
isLive,
};
}

View File

@ -0,0 +1,136 @@
'use client';
/**
* Shared typing indicator hook for rooms, issues, and PRs.
* Wraps the room-context's typing logic for use in non-room contexts.
*
* Usage:
* const { typingUsers } = useTypingIndicator({ roomId, wsClient });
* // or in issue/PR context:
* const { typingUsers } = useTypingIndicator({ projectName, issueNumber });
*/
import { useState, useEffect, useRef } from 'react';
import type { RoomWsClient } from '@/lib/room-ws-client';
import type { TypingStartPayload, TypingStopPayload } from '@/lib/ws-protocol';
export interface TypingUser {
userId: string;
username: string;
avatarUrl?: string;
}
interface UseTypingIndicatorOptions {
/** Room ID for room-scoped typing (used in room context) */
roomId?: string;
/** WebSocket client */
wsClient?: RoomWsClient | null;
/** Timeout in ms before a typing user is considered idle. Default: 4000ms */
timeout?: number;
}
export interface UseTypingIndicatorReturn {
/** Users currently typing in this context */
typingUsers: TypingUser[];
/** Send a typing start event */
sendTypingStart: () => void;
/** Send a typing stop event */
sendTypingStop: () => void;
}
/** Debounce timer ref helper */
function createTypingTracker(timeoutMs: number) {
const timers = new Map<string, ReturnType<typeof setTimeout>>();
function set(userId: string, onExpire: () => void) {
clear(userId);
timers.set(userId, setTimeout(onExpire, timeoutMs));
}
function clear(userId: string) {
const existing = timers.get(userId);
if (existing !== undefined) {
clearTimeout(existing);
timers.delete(userId);
}
}
function clearAll() {
for (const t of timers.values()) clearTimeout(t);
timers.clear();
}
return { set, clear, clearAll };
}
export function useTypingIndicator({
roomId,
wsClient,
timeout = 4000,
}: UseTypingIndicatorOptions): UseTypingIndicatorReturn {
const [typingUsers, setTypingUsers] = useState<TypingUser[]>([]);
const trackerRef = useRef(createTypingTracker(timeout));
const roomIdRef = useRef(roomId);
// Keep roomId ref current
useEffect(() => {
roomIdRef.current = roomId;
}, [roomId]);
// Register WS callbacks
useEffect(() => {
if (!wsClient || !roomId) return;
const tracker = trackerRef.current;
const handleTypingStart = (payload: TypingStartPayload) => {
// Only care about typing in this room
if (payload.room_id !== roomIdRef.current) return;
tracker.set(payload.user_id, () => {
setTypingUsers((prev) => prev.filter((u) => u.userId !== payload.user_id));
});
setTypingUsers((prev) => {
if (prev.some((u) => u.userId === payload.user_id)) return prev;
return [
...prev,
{
userId: payload.user_id,
username: payload.username,
avatarUrl: payload.avatar_url,
},
];
});
};
const handleTypingStop = (payload: TypingStopPayload) => {
if (payload.room_id !== roomIdRef.current) return;
tracker.clear(payload.user_id);
setTypingUsers((prev) => prev.filter((u) => u.userId !== payload.user_id));
};
wsClient.updateCallbacks({
onTypingStart: handleTypingStart,
onTypingStop: handleTypingStop,
});
return () => {
tracker.clearAll();
wsClient.updateCallbacks({ onTypingStart: undefined, onTypingStop: undefined });
setTypingUsers([]);
};
}, [wsClient, roomId, timeout]);
const sendTypingStart = () => {
if (!wsClient || !roomId) return;
wsClient.sendTyping?.(roomId, 'start');
};
const sendTypingStop = () => {
if (!wsClient || !roomId) return;
wsClient.sendTyping?.(roomId, 'stop');
};
return { typingUsers, sendTypingStart, sendTypingStop };
}

167
src/lib/code-lang-detect.ts Normal file
View File

@ -0,0 +1,167 @@
/**
* Language detection for code blocks using simple heuristics (no heavy ML).
* Returns a language label for display badges.
*/
export interface LanguageMatch {
/** Detected language label (e.g. "TypeScript", "Rust") */
label: string;
/** Short label for badges (e.g. "TS", "Rust") */
short: string;
/** Confidence: 0-1 */
confidence: number;
}
/** Map language aliases to canonical labels */
const LANG_ALIASES: Record<string, { label: string; short: string }> = {
ts: { label: 'TypeScript', short: 'TS' },
tsx: { label: 'TypeScript (React)', short: 'TSX' },
js: { label: 'JavaScript', short: 'JS' },
jsx: { label: 'JavaScript (React)', short: 'JSX' },
rs: { label: 'Rust', short: 'Rust' },
py: { label: 'Python', short: 'Py' },
go: { label: 'Go', short: 'Go' },
rb: { label: 'Ruby', short: 'RB' },
java: { label: 'Java', short: 'Java' },
kt: { label: 'Kotlin', short: 'Kt' },
swift: { label: 'Swift', short: 'Swift' },
cs: { label: 'C#', short: 'C#' },
cpp: { label: 'C++', short: 'C++' },
c: { label: 'C', short: 'C' },
h: { label: 'C/C++ Header', short: 'H' },
hpp: { label: 'C++ Header', short: 'HPP' },
css: { label: 'CSS', short: 'CSS' },
scss: { label: 'SCSS', short: 'SCSS' },
html: { label: 'HTML', short: 'HTML' },
xml: { label: 'XML', short: 'XML' },
json: { label: 'JSON', short: 'JSON' },
yaml: { label: 'YAML', short: 'YAML' },
yml: { label: 'YAML', short: 'YAML' },
md: { label: 'Markdown', short: 'MD' },
sh: { label: 'Shell', short: 'SH' },
bash: { label: 'Bash', short: 'Bash' },
zsh: { label: 'Zsh', short: 'Zsh' },
sql: { label: 'SQL', short: 'SQL' },
graphql: { label: 'GraphQL', short: 'GQL' },
dockerfile: { label: 'Dockerfile', short: 'Docker' },
tf: { label: 'Terraform', short: 'TF' },
toml: { label: 'TOML', short: 'TOML' },
ini: { label: 'INI', short: 'INI' },
env: { label: 'Env', short: 'ENV' },
proto: { label: 'Protocol Buffer', short: 'Proto' },
};
/** Language detection rules: pattern → language key */
const DETECTION_RULES: Array<{ pattern: RegExp; lang: string; confidence: number }> = [
// Rust
{ pattern: /\bfn\s+\w+\s*[\({]/, lang: 'rs', confidence: 0.9 },
{ pattern: /\blet\s+(mut\s+)?\w+\s*:/, lang: 'rs', confidence: 0.7 },
{ pattern: /\bimpl\s+\w+/, lang: 'rs', confidence: 0.8 },
{ pattern: /\buse\s+\w+::/, lang: 'rs', confidence: 0.7 },
{ pattern: /\bmatch\s+\w+\s*\{/, lang: 'rs', confidence: 0.8 },
{ pattern: /\b->\s*\w+/, lang: 'rs', confidence: 0.6 },
// TypeScript / JavaScript
{ pattern: /\bimport\s+.*\s+from\s+['"]/, lang: 'ts', confidence: 0.9 },
{ pattern: /\bexport\s+(default\s+)?(function|const|class|type|interface)/, lang: 'ts', confidence: 0.9 },
{ pattern: /\bconst\s+\w+:\s*\w+\s*=/, lang: 'ts', confidence: 0.8 },
{ pattern: /\blet\s+\w+:\s*\w+\s*=/, lang: 'ts', confidence: 0.8 },
{ pattern: /interface\s+\w+\s*\{/, lang: 'ts', confidence: 0.9 },
{ pattern: /\btype\s+\w+\s*=/, lang: 'ts', confidence: 0.8 },
{ pattern: /=>\s*[{(]?\s*\w+:/, lang: 'ts', confidence: 0.7 },
{ pattern: /<\w+[^>]*>.*<\/\w+>/s, lang: 'tsx', confidence: 0.7 },
// Python
{ pattern: /\bdef\s+\w+\s*\([^)]*\)\s*(->\s*\w+)?:/, lang: 'py', confidence: 0.95 },
{ pattern: /\bclass\s+\w+.*:/, lang: 'py', confidence: 0.8 },
{ pattern: /\bimport\s+\w+\s*,?\s*(from\s+\w+\s*)?/i, lang: 'py', confidence: 0.6 },
{ pattern: /\bprint\s*\(/, lang: 'py', confidence: 0.5 },
{ pattern: /\bself\./, lang: 'py', confidence: 0.8 },
{ pattern: /@\w+\s*\n/, lang: 'py', confidence: 0.6 },
// Go
{ pattern: /\bpackage\s+\w+/, lang: 'go', confidence: 0.95 },
{ pattern: /\bfunc\s+\w+\s*\(/, lang: 'go', confidence: 0.9 },
{ pattern: /\bfunc\s*\(/, lang: 'go', confidence: 0.8 },
{ pattern: /:=\s*/, lang: 'go', confidence: 0.7 },
{ pattern: /\bgo\s+func/, lang: 'go', confidence: 0.9 },
{ pattern: /\bchan\s+\w+/, lang: 'go', confidence: 0.8 },
// Java
{ pattern: /\bpublic\s+(class|interface|enum)\s+\w+/, lang: 'java', confidence: 0.95 },
{ pattern: /\bSystem\.out\.print/, lang: 'java', confidence: 0.9 },
// CSS
{ pattern: /\b[a-z-]+\s*:\s*[^;{}\n]+;/, lang: 'css', confidence: 0.7 },
{ pattern: /\.[a-z][\w-]*\s*\{/, lang: 'css', confidence: 0.8 },
{ pattern: /@media\s*\(/, lang: 'css', confidence: 0.9 },
{ pattern: /@import\s+/, lang: 'css', confidence: 0.8 },
// HTML
{ pattern: /<html[\s>]/i, lang: 'html', confidence: 0.9 },
{ pattern: /<div[\s>]/i, lang: 'html', confidence: 0.7 },
{ pattern: /<\/\w+>/, lang: 'html', confidence: 0.5 },
// SQL
{ pattern: /\bSELECT\s+.+\s+FROM\b/i, lang: 'sql', confidence: 0.95 },
{ pattern: /\bINSERT\s+INTO\b/i, lang: 'sql', confidence: 0.95 },
{ pattern: /\bCREATE\s+TABLE\b/i, lang: 'sql', confidence: 0.95 },
// Shell
{ pattern: /^#!/m, lang: 'sh', confidence: 0.95 },
{ pattern: /\becho\s+/, lang: 'sh', confidence: 0.6 },
{ pattern: /\bexport\s+\w+=/, lang: 'sh', confidence: 0.6 },
// JSON
{ pattern: /^\s*\{[\s\S]*"[\w-]+":\s*/, lang: 'json', confidence: 0.8 },
{ pattern: /^\s*\[[\s\S]*\{/, lang: 'json', confidence: 0.7 },
// YAML
{ pattern: /^\s*[\w-]+:\s*$/m, lang: 'yaml', confidence: 0.6 },
];
/** Normalize a language label/alias to a canonical label+short */
function normalize(lang: string): { label: string; short: string } {
const lower = lang.toLowerCase().trim();
if (LANG_ALIASES[lower]) return LANG_ALIASES[lower];
// Capitalize first letter for unknown
return { label: lang.charAt(0).toUpperCase() + lang.slice(1), short: lang.slice(0, 4) };
}
/**
* Detect the programming language of a code snippet.
* Returns the best match or null if no patterns match.
*/
export function detectLanguage(code: string): LanguageMatch | null {
let best: { lang: string; confidence: number } | null = null;
for (const rule of DETECTION_RULES) {
if (rule.pattern.test(code)) {
if (!best || rule.confidence > best.confidence) {
best = { lang: rule.lang, confidence: rule.confidence };
}
}
}
if (!best) return null;
return { ...normalize(best.lang), confidence: best.confidence };
}
/**
* Get a language label from a fenced code block language tag.
* Handles aliases and returns canonical label+short.
*/
export function getLanguageFromTag(tag: string | undefined): LanguageMatch | null {
if (!tag) return null;
const lower = tag.toLowerCase().trim();
if (LANG_ALIASES[lower]) {
return { ...LANG_ALIASES[lower], confidence: 1 };
}
return { label: tag, short: tag.slice(0, 4), confidence: 1 };
}
/** Extract the language tag from a fenced code block marker (e.g. "```ts" → "ts") */
export function extractLanguageFromFence(fence: string): string | undefined {
const match = fence.match(/^```(\w*)/);
return match?.[1] || undefined;
}

View File

@ -0,0 +1,81 @@
/**
* Parser for line-level code references.
* Handles formats like:
* file.rs:42
* src/main.rs:10-20
* #L42
* #L42-L48
* src/app.tsx:5
*/
export interface CodeRef {
/** File path (e.g. "src/main.rs") */
filePath: string;
/** Start line number (1-based) */
startLine: number;
/** End line number (inclusive, same as startLine for single line */
endLine: number;
/** The original reference text */
raw: string;
}
/** Parse a code reference string into structured data. */
export function parseCodeRef(ref: string): CodeRef | null {
// Format: path:start[-end]
// Example: src/main.rs:42, src/main.rs:10-20
const lineRangeMatch = ref.match(/^(.+):(\d+)(?:-(\d+))?$/);
if (lineRangeMatch) {
const [, filePath, startStr, endStr] = lineRangeMatch;
const startLine = parseInt(startStr, 10);
const endLine = endStr ? parseInt(endStr, 10) : startLine;
if (startLine > 0 && endLine >= startLine) {
return { filePath: filePath.trim(), startLine, endLine, raw: ref };
}
}
// Format: #L42 or #L42-L48 (GitHub-style)
const ghMatch = ref.match(/^#L(\d+)(?:-L(\d+))?$/i);
if (ghMatch) {
const [, startStr, endStr] = ghMatch;
const startLine = parseInt(startStr, 10);
const endLine = endStr ? parseInt(endStr, 10) : startLine;
if (startLine > 0 && endLine >= startLine) {
return { filePath: '', startLine, endLine, raw: ref };
}
}
return null;
}
/** Extract all code references from a text string. */
export function extractCodeRefs(text: string): CodeRef[] {
const refs: CodeRef[] = [];
// Match file:line or file:line-line patterns
const linePattern = /([^\s:]+:\d+(?:-\d+)?)/g;
let match;
while ((match = linePattern.exec(text)) !== null) {
const parsed = parseCodeRef(match[1]);
if (parsed) refs.push(parsed);
}
// Match #Lnn or #Lnn-Lmm (GitHub-style)
const ghPattern = /#L(\d+)(?:-L(\d+))?/gi;
while ((match = ghPattern.exec(text)) !== null) {
const startLine = parseInt(match[1], 10);
const endLine = match[2] ? parseInt(match[2], 10) : startLine;
if (startLine > 0 && endLine >= startLine) {
refs.push({ filePath: '', startLine, endLine, raw: match[0] });
}
}
return refs;
}
/** Format a code reference as a display string. */
export function formatCodeRef(ref: CodeRef): string {
if (ref.endLine === ref.startLine) {
return `${ref.filePath}:${ref.startLine}`;
}
return `${ref.filePath}:${ref.startLine}-${ref.endLine}`;
}

142
src/lib/link-unfurl.ts Normal file
View File

@ -0,0 +1,142 @@
/**
* URL pattern detection and unfurling for smart link previews.
* Supports internal URLs (issues, PRs, commits, repos) and external URLs.
*/
export type LinkType =
| 'issue'
| 'pull_request'
| 'commit'
| 'repository'
| 'room'
| 'project'
| 'external';
export interface UnfurlResult {
type: LinkType;
url: string;
/** Parsed entity ID fields */
ids: {
project?: string;
namespace?: string;
repo?: string;
issueNumber?: number;
prNumber?: number;
commitOid?: string;
roomId?: string;
};
/** Display title (populated after fetch) */
title?: string;
/** Extra metadata (populated after fetch) */
meta?: Record<string, unknown>;
/** Whether the URL is external */
isExternal?: boolean;
/** Domain for external URLs */
domain?: string;
}
/** Internal URL patterns */
const INTERNAL_PATTERNS: Array<{
type: LinkType;
regex: RegExp;
extract: (match: RegExpMatchArray) => UnfurlResult['ids'];
}> = [
{
type: 'issue',
regex: /^\/project\/([^/]+)\/issues\/(\d+)/,
extract: (m) => ({ project: m[1], issueNumber: parseInt(m[2], 10) }),
},
{
type: 'pull_request',
regex: /^\/repository\/([^/]+)\/([^/]+)\/pulls?\/(\d+)/,
extract: (m) => ({ namespace: m[1], repo: m[2], prNumber: parseInt(m[3], 10) }),
},
{
type: 'commit',
regex: /^\/repository\/([^/]+)\/([^/]+)\/commit\/([a-f0-9]+)/,
extract: (m) => ({ namespace: m[1], repo: m[2], commitOid: m[3] }),
},
{
type: 'repository',
regex: /^\/repository\/([^/]+)\/([^/]+)/,
extract: (m) => ({ namespace: m[1], repo: m[2] }),
},
{
type: 'project',
regex: /^\/project\/([^/]+)/,
extract: (m) => ({ project: m[1] }),
},
{
type: 'room',
regex: /^\/project\/([^/]+)\/room(?:\/([^/?#]+))?/,
extract: (m) => ({ project: m[1], roomId: m[2] }),
},
];
/** Detect the type and IDs of a URL */
export function detectLinkType(url: string): UnfurlResult | null {
// Remove trailing slashes and hash
const normalized = url.split('?')[0].split('#')[0].replace(/\/+$/, '');
// Check internal patterns
for (const pattern of INTERNAL_PATTERNS) {
const match = normalized.match(pattern.regex);
if (match) {
return {
type: pattern.type,
url: `/${normalized.replace(/^\//, '')}`,
ids: pattern.extract(match),
};
}
}
// External URL
try {
const parsed = new URL(url);
const isExternal = !parsed.hostname.includes(window.location.hostname);
return {
type: 'external',
url,
ids: {},
isExternal,
domain: parsed.hostname,
};
} catch {
return null;
}
}
/** Extract all URLs from a text string */
export function extractUrls(text: string): Array<{ url: string; index: number }> {
// Match URLs (including internal paths starting with /)
const urlPattern = /(?:https?:\/\/[^\s<>"]+|^\/[^\s<>"]+|\/[^\s<>"]+)/gm;
const results: Array<{ url: string; index: number }> = [];
let match;
while ((match = urlPattern.exec(text)) !== null) {
const url = match[0];
// Filter out very short or malformed URLs
if (url.length > 3) {
results.push({ url, index: match.index });
}
}
return results;
}
/** Simple cache for unfurl results */
const unfurlCache = new Map<string, UnfurlResult & { expiresAt: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
export function getCachedUnfurl(url: string): (UnfurlResult & { expiresAt: number }) | undefined {
const cached = unfurlCache.get(url);
if (cached && cached.expiresAt > Date.now()) {
return cached;
}
return undefined;
}
export function setCachedUnfurl(url: string, result: UnfurlResult): void {
unfurlCache.set(url, { ...result, expiresAt: Date.now() + CACHE_TTL });
}

100
src/lib/mention.ts Normal file
View File

@ -0,0 +1,100 @@
/**
* Unified mention serialization and parsing utilities.
* Shared across room chat, issues, and PR comments.
*/
export type MentionType =
| 'user'
| 'channel'
| 'ai'
| 'command'
| 'special_here'
| 'special_channel';
export const MENTION_TYPES: MentionType[] = [
'user',
'channel',
'ai',
'command',
'special_here',
'special_channel',
];
export interface MentionSpan {
type: MentionType;
id: string;
label: string;
}
export interface ExtractedMentions {
safeContent: string;
mentions: MentionSpan[];
}
/**
* Parse @[type:id:label] tokens from message content.
* Returns the safe content (with mentions replaced by placeholders) and the parsed mention list.
*
* Placeholders use zero-width spaces to prevent markdown from interpreting the text.
*/
const PLACEHOLDER_PREFIX = 'MENTION_';
const PLACEHOLDER_SUFFIX = '';
const MENTION_RE = /@\[([a-z_]+):([^:\]]+):([^\]]+)\]/g;
const PLACEHOLDER_RE = /MENTION_(\d+)/g;
export function extractMentions(content: string): ExtractedMentions {
const mentions: MentionSpan[] = [];
const safeContent = content.replace(MENTION_RE, (_match, type, id, label) => {
const idx = mentions.length;
mentions.push({ type: type as MentionType, id, label });
return `${PLACEHOLDER_PREFIX}${idx}${PLACEHOLDER_SUFFIX}`;
});
return { safeContent, mentions };
}
export function restoreMentions(
text: string,
mentions: MentionSpan[],
): string {
return text.replace(PLACEHOLDER_RE, (match, idxStr) => {
const idx = parseInt(idxStr, 10);
const m = mentions[idx];
return m ? `@[${m.type}:${m.id}:${m.label}]` : match;
});
}
/** Build a mention token string for serialization. */
export function serializeMention(type: MentionType, id: string, label: string): string {
return `@[${type}:${id}:${label}]`;
}
/** Build a mention token from a user mention. */
export function serializeUserMention(userId: string, displayName: string): string {
return serializeMention('user', userId, displayName);
}
/** Build a mention token from a channel mention. */
export function serializeChannelMention(channelId: string, channelName: string): string {
return serializeMention('channel', channelId, channelName);
}
/** Build a mention token from an AI mention. */
export function serializeAiMention(aiId: string, aiName: string): string {
return serializeMention('ai', aiId, aiName);
}
/** Build a mention token from a command mention. */
export function serializeCommandMention(commandId: string, commandName: string): string {
return serializeMention('command', commandId, commandName);
}
/** Build a mention token for @here. */
export function serializeHereMention(): string {
return '@[special_here:here:here]';
}
/** Build a mention token for @channel. */
export function serializeChannelBroadcastMention(): string {
return '@[special_channel:channel:channel]';
}

View File

@ -6,6 +6,9 @@ import {UserProvider} from '@/contexts';
import {ThemeProvider} from '@/contexts/theme-context';
import './index.css';
import App from './App.tsx';
import {CommandPalette} from '@/components/shared/CommandPalette';
import {KeyboardShortcutsSheet} from '@/components/shared/KeyboardShortcutsSheet';
import {GlobalNavigationShortcuts} from '@/components/shared/GlobalNavigationShortcuts';
import {applyPaletteToDOM, loadActivePresetId} from '@/components/room/design-system';
// Restore custom palette on page load (before first render)
@ -30,6 +33,9 @@ createRoot(document.getElementById('root')!).render(
<UserProvider>
<ThemeProvider>
<App/>
<CommandPalette/>
<KeyboardShortcutsSheet/>
<GlobalNavigationShortcuts/>
<Toaster richColors position="bottom-right"/>
</ThemeProvider>
</UserProvider>