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:
parent
f7e087e066
commit
dfa5f7664a
37
libs/api/agent/issue_triage.rs
Normal file
37
libs/api/agent/issue_triage.rs
Normal 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())
|
||||
}
|
||||
365
src/components/notify/NotificationDrawer.tsx
Normal file
365
src/components/notify/NotificationDrawer.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
86
src/hooks/useCommandRegistry.ts
Normal file
86
src/hooks/useCommandRegistry.ts
Normal 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);
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
174
src/hooks/useKeyboardShortcut.ts
Normal file
174
src/hooks/useKeyboardShortcut.ts
Normal 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('+');
|
||||
}
|
||||
229
src/hooks/useNotification.ts
Normal file
229
src/hooks/useNotification.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
136
src/hooks/useTypingIndicator.ts
Normal file
136
src/hooks/useTypingIndicator.ts
Normal 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
167
src/lib/code-lang-detect.ts
Normal 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;
|
||||
}
|
||||
81
src/lib/code-ref-parser.ts
Normal file
81
src/lib/code-ref-parser.ts
Normal 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
142
src/lib/link-unfurl.ts
Normal 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
100
src/lib/mention.ts
Normal 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]';
|
||||
}
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user