From dfa5f7664ab62a5ef36786e31111b7c9b7250b93 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Sat, 25 Apr 2026 09:53:49 +0800 Subject: [PATCH] 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 --- libs/api/agent/issue_triage.rs | 37 ++ src/components/notify/NotificationDrawer.tsx | 365 +++++++++++++++++++ src/hooks/useCommandRegistry.ts | 86 +++++ src/hooks/useKeyboardShortcut.ts | 174 +++++++++ src/hooks/useNotification.ts | 229 ++++++++++++ src/hooks/useTypingIndicator.ts | 136 +++++++ src/lib/code-lang-detect.ts | 167 +++++++++ src/lib/code-ref-parser.ts | 81 ++++ src/lib/link-unfurl.ts | 142 ++++++++ src/lib/mention.ts | 100 +++++ src/main.tsx | 6 + 11 files changed, 1523 insertions(+) create mode 100644 libs/api/agent/issue_triage.rs create mode 100644 src/components/notify/NotificationDrawer.tsx create mode 100644 src/hooks/useCommandRegistry.ts create mode 100644 src/hooks/useKeyboardShortcut.ts create mode 100644 src/hooks/useNotification.ts create mode 100644 src/hooks/useTypingIndicator.ts create mode 100644 src/lib/code-lang-detect.ts create mode 100644 src/lib/code-ref-parser.ts create mode 100644 src/lib/link-unfurl.ts create mode 100644 src/lib/mention.ts diff --git a/libs/api/agent/issue_triage.rs b/libs/api/agent/issue_triage.rs new file mode 100644 index 0000000..12ee29d --- /dev/null +++ b/libs/api/agent/issue_triage.rs @@ -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), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Issue not found"), + ), + tag = "Agent" +)] +pub async fn triage_issue( + service: web::Data, + _session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let project_name = path.into_inner(); + let resp = service + .triage_issue(project_name, query.issue_number) + .await?; + Ok(crate::ApiResponse::ok(resp).to_response()) +} diff --git a/src/components/notify/NotificationDrawer.tsx b/src/components/notify/NotificationDrawer.tsx new file mode 100644 index 0000000..f4f79d8 --- /dev/null +++ b/src/components/notify/NotificationDrawer.tsx @@ -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: , + color: 'bg-blue-500/10 text-blue-600 border-blue-500/20', + }, + invitation: { + label: 'Invitation', + icon: , + color: 'bg-purple-500/10 text-purple-600 border-purple-500/20', + }, + role_change: { + label: 'Role Change', + icon: , + color: 'bg-orange-500/10 text-orange-600 border-orange-500/20', + }, + room_created: { + label: 'Room Created', + icon: , + color: 'bg-green-500/10 text-green-600 border-green-500/20', + }, + room_deleted: { + label: 'Room Deleted', + icon: , + color: 'bg-red-500/10 text-red-600 border-red-500/20', + }, + system_announcement: { + label: 'Announcement', + icon: , + color: 'bg-yellow-500/10 text-yellow-700 border-yellow-500/20', + }, + // Extended types + issue_opened: { + label: 'Issue Opened', + icon: , + color: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20', + }, + issue_closed: { + label: 'Issue Closed', + icon: , + color: 'bg-violet-500/10 text-violet-600 border-violet-500/20', + }, + pr_review_requested: { + label: 'Review Requested', + icon: , + color: 'bg-amber-500/10 text-amber-600 border-amber-500/20', + }, + pr_approved: { + label: 'PR Approved', + icon: , + color: 'bg-green-500/10 text-green-600 border-green-500/20', + }, + pr_merged: { + label: 'PR Merged', + icon: , + 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: , + color: 'bg-muted text-muted-foreground border-border', + }; + + return ( +
{ + if (!n.is_read) onMarkRead(n.id); + onNavigate(n); + }} + > + {/* Unread dot */} +
+ {!n.is_read &&
} +
+ + {/* Icon */} +
+ {config.icon} +
+ + {/* Content */} +
+

+ {n.title} +

+ {n.content && ( +

+ {n.content} +

+ )} +
+ + {config.label} + + {formatTime(n.created_at)} +
+
+ + {/* Actions */} +
+ {!n.is_read && ( + + )} + +
+
+ ); +} + +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 */} + + + {/* Drawer sheet */} + + + +
+ Notifications + {isLive && ( + + + Live + + )} +
+
+ {unreadCount > 0 && ( + + )} + +
+
+ + {/* Unread count */} + {unreadCount > 0 && ( +
+ + {unreadCount} unread + {unreadCount > 5 && ( + + )} + +
+ )} + + {/* Notification list */} +
+ {drawerNotifications.length === 0 ? ( +
+ +

No notifications yet

+

You'll see updates here when something happens.

+
+ ) : ( + drawerNotifications.map((n) => ( + + )) + )} +
+ + {/* Footer */} +
+ +
+
+
+ + ); +} diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts new file mode 100644 index 0000000..558062a --- /dev/null +++ b/src/hooks/useCommandRegistry.ts @@ -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); + } + }, []); +} diff --git a/src/hooks/useKeyboardShortcut.ts b/src/hooks/useKeyboardShortcut.ts new file mode 100644 index 0000000..2571c81 --- /dev/null +++ b/src/hooks/useKeyboardShortcut.ts @@ -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({ + 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('+'); +} diff --git a/src/hooks/useNotification.ts b/src/hooks/useNotification.ts new file mode 100644 index 0000000..7e4e7c3 --- /dev/null +++ b/src/hooks/useNotification.ts @@ -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 { + 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): 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; + /** Mark all notifications as read. */ + markAllRead: () => Promise; + /** Archive a notification. */ + archive: (id: string) => Promise; + /** 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([]); + const [isLive, setIsLive] = useState(false); + const seenIdsRef = useRef>(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(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(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(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, + }; +} diff --git a/src/hooks/useTypingIndicator.ts b/src/hooks/useTypingIndicator.ts new file mode 100644 index 0000000..67a0e78 --- /dev/null +++ b/src/hooks/useTypingIndicator.ts @@ -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>(); + + 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([]); + 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 }; +} diff --git a/src/lib/code-lang-detect.ts b/src/lib/code-lang-detect.ts new file mode 100644 index 0000000..06e730c --- /dev/null +++ b/src/lib/code-lang-detect.ts @@ -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 = { + 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: /]/i, lang: 'html', confidence: 0.9 }, + { pattern: /]/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; +} diff --git a/src/lib/code-ref-parser.ts b/src/lib/code-ref-parser.ts new file mode 100644 index 0000000..50e6012 --- /dev/null +++ b/src/lib/code-ref-parser.ts @@ -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}`; +} diff --git a/src/lib/link-unfurl.ts b/src/lib/link-unfurl.ts new file mode 100644 index 0000000..1596bad --- /dev/null +++ b/src/lib/link-unfurl.ts @@ -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; + /** 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(); +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 }); +} diff --git a/src/lib/mention.ts b/src/lib/mention.ts new file mode 100644 index 0000000..6109c0a --- /dev/null +++ b/src/lib/mention.ts @@ -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]'; +} diff --git a/src/main.tsx b/src/main.tsx index b098bdc..144de39 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( + + +