feat(command): use real API data for navigation, fix notification button
CommandPalette: replace workspaceProjects with getCurrentUserProjects (no workspace dependency so it works outside WorkspaceProvider). Repos fetched per-project to preserve correct /repository/ns/repo routes. Keyboard shortcut correctly matches Ctrl+Alt+F / Cmd+Ctrl+F. sidebar-user: fix notification button layout — bell icon and label now on the same row instead of separate stacked elements.
This commit is contained in:
parent
616c0c0e88
commit
7620f2f281
@ -10,8 +10,9 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {useUser} from '@/contexts';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {Mail, UserPlus} from 'lucide-react';
|
||||
import {UserPlus, Bell} from 'lucide-react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {NotificationDrawer} from '@/components/notify/NotificationDrawer';
|
||||
|
||||
const btnClass = 'flex w-full h-9 justify-start items-center rounded-md font-medium hover:bg-muted cursor-pointer bg-transparent border-0 text-left text-sm';
|
||||
|
||||
@ -29,19 +30,19 @@ export function SidebarUser({collapsed}: { collapsed: boolean }) {
|
||||
{!collapsed && <span className="text-sm leading-none">Invitations</span>}
|
||||
</button>
|
||||
|
||||
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')}
|
||||
{!collapsed ? (
|
||||
<button type="button" className={cn(btnClass, 'px-2')}
|
||||
onClick={() => navigate('/notify')}>
|
||||
<span className="relative flex h-6 items-center shrink-0 w-6">
|
||||
<Mail className="h-4 w-4"/>
|
||||
{user && user.has_unread_notifications > 0 && (
|
||||
<span
|
||||
className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white">
|
||||
{user.has_unread_notifications > 9 ? '9+' : user.has_unread_notifications}
|
||||
<span className="flex h-6 items-center shrink-0 w-6">
|
||||
<Bell className="h-4 w-4"/>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!collapsed && <span className="text-sm leading-none">Notify</span>}
|
||||
<span className="text-sm leading-none">Notify</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
<NotificationDrawer />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user && (
|
||||
<DropdownMenu>
|
||||
|
||||
155
src/components/shared/CodeBlock.tsx
Normal file
155
src/components/shared/CodeBlock.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Enhanced code block component with:
|
||||
* - Language badge (from code-lang-detect.ts)
|
||||
* - Copy button
|
||||
* - Optional line numbers
|
||||
* - Whitespace toggle
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Copy, Check, WrapText } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { detectLanguage, getLanguageFromTag } from '@/lib/code-lang-detect';
|
||||
|
||||
interface CodeBlockProps {
|
||||
children: string;
|
||||
/** Language tag from the fenced code block (e.g. "ts", "rust") */
|
||||
language?: string;
|
||||
/** Auto-detect language from content if no tag provided */
|
||||
autoDetect?: boolean;
|
||||
/** Show line numbers */
|
||||
showLineNumbers?: boolean;
|
||||
className?: string;
|
||||
/** Additional header content (e.g. file name) */
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export function CodeBlock({
|
||||
children,
|
||||
language,
|
||||
autoDetect = true,
|
||||
showLineNumbers = false,
|
||||
className,
|
||||
filename,
|
||||
}: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [wrap, setWrap] = useState(false);
|
||||
|
||||
// Resolve language
|
||||
const langInfo = language
|
||||
? getLanguageFromTag(language)
|
||||
: autoDetect
|
||||
? detectLanguage(children)
|
||||
: null;
|
||||
|
||||
const displayLang = langInfo?.label ?? language ?? null;
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(children);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = children;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const lines = children.split('\n');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border bg-muted overflow-hidden my-2',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b bg-muted/70">
|
||||
{/* Traffic light dots (macOS style) */}
|
||||
<div className="flex gap-1.5 -ml-1">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-red-500/60" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-yellow-500/60" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-green-500/60" />
|
||||
</div>
|
||||
|
||||
{filename && (
|
||||
<span className="text-xs font-mono text-muted-foreground ml-1 truncate max-w-48">
|
||||
{filename}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{displayLang && (
|
||||
<span className="text-xs font-mono bg-muted-foreground/10 text-muted-foreground px-1.5 py-0.5 rounded">
|
||||
{displayLang}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{showLineNumbers && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWrap((w) => !w)}
|
||||
className={cn(
|
||||
'p-1 rounded hover:bg-muted-foreground/10 transition-colors',
|
||||
wrap && 'text-primary',
|
||||
)}
|
||||
title={wrap ? 'Disable word wrap' : 'Enable word wrap'}
|
||||
>
|
||||
<WrapText className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="p-1 rounded hover:bg-muted-foreground/10 transition-colors"
|
||||
title={copied ? 'Copied!' : 'Copy code'}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code content */}
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-x-auto',
|
||||
wrap && 'whitespace-pre-wrap',
|
||||
)}
|
||||
>
|
||||
<pre className="p-3 font-mono text-sm leading-5 text-foreground/90">
|
||||
{showLineNumbers ? (
|
||||
<table className="w-full border-collapse">
|
||||
<tbody>
|
||||
{lines.map((line, i) => (
|
||||
<tr key={i}>
|
||||
<td className="select-none pr-3 text-right text-muted-foreground/40 text-xs w-8 align-top">
|
||||
{i + 1}
|
||||
</td>
|
||||
<td className="text-foreground/90 whitespace-pre">{line}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
src/components/shared/CodeReference.tsx
Normal file
154
src/components/shared/CodeReference.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Renders a compact code reference block (file path + line range).
|
||||
* Clicking navigates to the file in the repository browser.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { FileCode2, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CodeRef } from '@/lib/code-ref-parser';
|
||||
|
||||
interface CodeReferenceProps {
|
||||
ref: CodeRef;
|
||||
/** API to fetch line content (optional — shows skeleton if not provided) */
|
||||
getLineContent?: (filePath: string, startLine: number, endLine: number) => Promise<string[]>;
|
||||
/** Called when the reference is clicked — navigate to file browser */
|
||||
onClick?: (ref: CodeRef) => void;
|
||||
/** Base URL for the repository file browser (e.g. /repository/ns/repo) */
|
||||
repoBaseUrl?: string;
|
||||
/** Branch name to use in the link */
|
||||
branch?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CodeReference({
|
||||
ref,
|
||||
getLineContent,
|
||||
onClick,
|
||||
repoBaseUrl,
|
||||
branch = 'main',
|
||||
className,
|
||||
}: CodeReferenceProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [lines, setLines] = useState<string[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick(ref);
|
||||
return;
|
||||
}
|
||||
if (repoBaseUrl) {
|
||||
// Navigate to file browser with line highlighted
|
||||
window.location.href = `${repoBaseUrl}/blob/${branch}/${ref.filePath}#L${ref.startLine}`;
|
||||
}
|
||||
};
|
||||
|
||||
const loadLines = async () => {
|
||||
if (!getLineContent || lines !== null) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const content = await getLineContent(ref.filePath, ref.startLine, ref.endLine);
|
||||
setLines(content);
|
||||
} catch {
|
||||
setLines([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExpand = () => {
|
||||
setExpanded((prev) => {
|
||||
if (!prev) loadLines();
|
||||
return !prev;
|
||||
});
|
||||
};
|
||||
|
||||
const lineLabel = ref.endLine === ref.startLine
|
||||
? `L${ref.startLine}`
|
||||
: `L${ref.startLine}–L${ref.endLine}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border bg-muted/50 my-1.5 overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Header bar */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-muted transition-colors text-left"
|
||||
onClick={handleClick}
|
||||
title={ref.filePath ? `View ${ref.filePath}` : `Jump to ${lineLabel}`}
|
||||
>
|
||||
<FileCode2 className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
{ref.filePath && (
|
||||
<span className="text-xs font-mono text-muted-foreground truncate">
|
||||
{ref.filePath}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">
|
||||
{lineLabel}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Code preview (optional) */}
|
||||
{getLineContent && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-1 px-3 py-0.5 hover:bg-muted/70 transition-colors text-left"
|
||||
onClick={toggleExpand}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{expanded ? 'Hide preview' : 'Show preview'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t">
|
||||
{loading ? (
|
||||
<div className="px-3 py-2 space-y-1">
|
||||
{Array.from({ length: Math.min(ref.endLine - ref.startLine + 1, 5) }).map((_, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<div className="h-3 w-6 bg-muted rounded animate-pulse" />
|
||||
<div className="h-3 flex-1 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : lines && lines.length > 0 ? (
|
||||
<div className="py-1">
|
||||
{lines.map((line, i) => {
|
||||
const lineNum = ref.startLine + i;
|
||||
return (
|
||||
<div key={i} className="flex gap-0 px-1 hover:bg-muted/50">
|
||||
<span className="select-none w-10 text-right pr-2 text-xs text-muted-foreground/50 font-mono shrink-0">
|
||||
{lineNum}
|
||||
</span>
|
||||
<span className="text-xs font-mono leading-5 text-foreground/90 whitespace-pre">
|
||||
{line || ' '}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground italic">
|
||||
No content available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
277
src/components/shared/CommandPalette.tsx
Normal file
277
src/components/shared/CommandPalette.tsx
Normal file
@ -0,0 +1,277 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Global command palette (Cmd+K / Ctrl+K).
|
||||
* Dynamic: fetches real projects, repos, rooms from API.
|
||||
* Actions: navigate to project/repo/room, create project, create repo.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
} from '@/components/ui/command';
|
||||
import {
|
||||
FolderKanban,
|
||||
GitBranch,
|
||||
Hash,
|
||||
Plus,
|
||||
Bell,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { getRegisteredCommands } from '@/hooks/useCommandRegistry';
|
||||
import type { CommandItem as RegistryCommandItem } from '@/hooks/useCommandRegistry';
|
||||
import { formatShortcut } from '@/hooks/useKeyboardShortcut';
|
||||
import { getCurrentUserProjects, projectRepos, roomList } from '@/client';
|
||||
import type { UserProjectInfo, ProjectRepositoryItem, RoomResponse } from '@/client';
|
||||
|
||||
// ── Icons ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
FolderKanban,
|
||||
GitBranch,
|
||||
Hash,
|
||||
Plus,
|
||||
Bell,
|
||||
Search,
|
||||
};
|
||||
|
||||
// ── Command item type ────────────────────────────────────────────────────────
|
||||
|
||||
interface PaletteItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
shortcut?: { key: string; meta?: boolean; shift?: boolean };
|
||||
action: () => void;
|
||||
group: string;
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
// ── Static quick actions ─────────────────────────────────────────────────────
|
||||
|
||||
function buildStaticActions(navigate: ReturnType<typeof useNavigate>): PaletteItem[] {
|
||||
return [
|
||||
{
|
||||
id: 'goto-notifications',
|
||||
label: 'Go to Notifications',
|
||||
icon: 'Bell',
|
||||
shortcut: { key: 'n', meta: true },
|
||||
action: () => navigate('/notify'),
|
||||
group: 'Navigation',
|
||||
keywords: ['goto', 'notifications', 'inbox'],
|
||||
},
|
||||
{
|
||||
id: 'create-project',
|
||||
label: 'Create Project',
|
||||
icon: 'Plus',
|
||||
shortcut: { key: 'c', meta: true, shift: true },
|
||||
action: () => navigate('/init/project'),
|
||||
group: 'Create',
|
||||
keywords: ['create', 'new', 'project'],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Main palette ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Fetch projects for the current user (no workspace dependency)
|
||||
const { data: projectsData } = useQuery({
|
||||
queryKey: ['commandPaletteProjects'],
|
||||
queryFn: async () => {
|
||||
const resp = await getCurrentUserProjects();
|
||||
return resp.data?.data ?? { projects: [] as UserProjectInfo[], total_count: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
const projects = projectsData?.projects ?? [];
|
||||
|
||||
// Derived project names for repo/room queries
|
||||
const projectNames = useMemo(
|
||||
() => projects.map(p => p.name),
|
||||
[projects],
|
||||
);
|
||||
|
||||
// Fetch repos per project (up to 5 projects to keep it fast)
|
||||
const { data: reposData } = useQuery({
|
||||
queryKey: ['commandPaletteRepos', projectNames],
|
||||
queryFn: async () => {
|
||||
const results: Record<string, ProjectRepositoryItem[]> = {};
|
||||
for (const name of projectNames.slice(0, 5)) {
|
||||
try {
|
||||
const resp = await projectRepos({ path: { project_name: name } });
|
||||
const data = resp.data?.data;
|
||||
if (data?.items) results[name] = data.items;
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
return results;
|
||||
},
|
||||
enabled: projectNames.length > 0,
|
||||
});
|
||||
|
||||
// Fetch rooms for each project (up to 5 to keep it fast)
|
||||
|
||||
const { data: roomsData } = useQuery({
|
||||
queryKey: ['commandPaletteRooms', projectNames],
|
||||
queryFn: async () => {
|
||||
const results: Record<string, RoomResponse[]> = {};
|
||||
for (const name of projectNames.slice(0, 5)) {
|
||||
try {
|
||||
const resp = await roomList({ path: { project_name: name } });
|
||||
const data = resp.data?.data;
|
||||
if (data) results[name] = (data as any)?.rooms ?? (Array.isArray(data) ? data : []);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
return results;
|
||||
},
|
||||
enabled: projectNames.length > 0,
|
||||
});
|
||||
|
||||
// Global shortcut: Ctrl+Alt+F (Windows/Linux) or Cmd+Ctrl+F (Mac)
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const isMac = navigator.platform?.toUpperCase().includes('MAC') || navigator.userAgent?.includes('Mac');
|
||||
const match = isMac
|
||||
? e.metaKey && e.ctrlKey && e.key === 'f'
|
||||
: e.ctrlKey && e.altKey && e.key === 'f';
|
||||
if (match) {
|
||||
e.preventDefault();
|
||||
setOpen((v) => !v);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, []);
|
||||
|
||||
// Close on navigation
|
||||
useEffect(() => {
|
||||
const handler = () => setOpen(false);
|
||||
window.addEventListener('popstate', handler);
|
||||
return () => window.removeEventListener('popstate', handler);
|
||||
}, []);
|
||||
|
||||
const handleSelect = useCallback((action: () => void) => {
|
||||
setOpen(false);
|
||||
action();
|
||||
}, []);
|
||||
|
||||
// Build search text including keywords for cmdk filtering
|
||||
const searchValue = useCallback((item: PaletteItem | RegistryCommandItem) => {
|
||||
return [item.label, ...(item.keywords ?? [])].join(' ');
|
||||
}, []);
|
||||
|
||||
// ── Build dynamic command list ──────────────────────────────────────────
|
||||
|
||||
const allRooms = roomsData ?? {};
|
||||
const allRepos = reposData ?? {};
|
||||
|
||||
const projectItems: PaletteItem[] = projects.map(p => ({
|
||||
id: `project-${p.name}`,
|
||||
label: p.display_name || p.name,
|
||||
icon: 'FolderKanban',
|
||||
action: () => navigate(`/project/${p.name}`),
|
||||
group: 'Projects',
|
||||
keywords: ['project', p.name, p.display_name, ...(p.description ? [p.description] : [])],
|
||||
}));
|
||||
|
||||
const repoItems: PaletteItem[] = [];
|
||||
for (const [projName, repos] of Object.entries(allRepos)) {
|
||||
for (const r of repos) {
|
||||
repoItems.push({
|
||||
id: `repo-${projName}-${r.repo_name}`,
|
||||
label: `${r.repo_name} (${projName})`,
|
||||
icon: 'GitBranch',
|
||||
action: () => navigate(`/repository/${projName}/${r.repo_name}`),
|
||||
group: 'Repositories',
|
||||
keywords: ['repo', 'repository', r.repo_name, projName, ...(r.description ? [r.description] : [])],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const roomItems: PaletteItem[] = [];
|
||||
for (const [projName, rooms] of Object.entries(allRooms)) {
|
||||
for (const r of rooms) {
|
||||
roomItems.push({
|
||||
id: `room-${r.id}`,
|
||||
label: `${r.room_name} (${projName})`,
|
||||
icon: 'Hash',
|
||||
action: () => navigate(`/project/${projName}/room/${r.id}`),
|
||||
group: 'Rooms',
|
||||
keywords: ['room', 'chat', 'channel', r.room_name, projName],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Per-project "create repo" actions
|
||||
const createRepoItems: PaletteItem[] = projects.slice(0, 10).map(p => ({
|
||||
id: `create-repo-${p.name}`,
|
||||
label: `Create Repo in ${p.display_name || p.name}`,
|
||||
icon: 'Plus',
|
||||
action: () => navigate(`/project/${p.name}/repositories`),
|
||||
group: 'Create',
|
||||
keywords: ['create', 'new', 'repo', 'repository', p.name, p.display_name],
|
||||
}));
|
||||
|
||||
const staticActions = buildStaticActions(navigate);
|
||||
const registered = getRegisteredCommands();
|
||||
const allCommands = [
|
||||
...staticActions,
|
||||
...projectItems,
|
||||
...repoItems,
|
||||
...roomItems,
|
||||
...createRepoItems,
|
||||
...registered,
|
||||
] as (PaletteItem | RegistryCommandItem)[];
|
||||
|
||||
// Group commands by their `group` field
|
||||
const grouped = useMemo(() => {
|
||||
const groups = new Map<string, (PaletteItem | RegistryCommandItem)[]>();
|
||||
for (const cmd of allCommands) {
|
||||
const g = groups.get(cmd.group) ?? [];
|
||||
g.push(cmd);
|
||||
groups.set(cmd.group, g);
|
||||
}
|
||||
return groups;
|
||||
}, [allCommands]);
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder="Search projects, repos, rooms, commands…" autoFocus />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
{Array.from(grouped.entries()).map(([group, cmds]) => (
|
||||
<CommandGroup key={group} heading={group}>
|
||||
{cmds.map((cmd) => {
|
||||
const Icon = iconMap[(cmd as PaletteItem).icon ?? ''] ?? Search;
|
||||
const shortcut = cmd.shortcut;
|
||||
return (
|
||||
<CommandItem
|
||||
key={cmd.id}
|
||||
value={searchValue(cmd)}
|
||||
onSelect={() => handleSelect(cmd.action)}
|
||||
>
|
||||
<Icon className="mr-2 h-4 w-4 shrink-0" />
|
||||
<span className="flex-1">{cmd.label}</span>
|
||||
{shortcut && (
|
||||
<CommandShortcut>{formatShortcut(shortcut)}</CommandShortcut>
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
323
src/components/shared/ContentRenderer.tsx
Normal file
323
src/components/shared/ContentRenderer.tsx
Normal file
@ -0,0 +1,323 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Unified content renderer for markdown + @[type:id:label] mentions.
|
||||
* Used across room chat, issues, and PR comments.
|
||||
*
|
||||
* Features (enabled via props):
|
||||
* - enableLinkPreviews: inline rich link previews for issues, PRs, commits, repos
|
||||
* - enableCodeRefs: inline code reference blocks (file.rs:42 style)
|
||||
*
|
||||
* Mentions are protected from markdown parsing by replacing them with
|
||||
* zero-width-space-delimited placeholder tokens before rendering,
|
||||
* then restored as styled interactive elements after.
|
||||
*/
|
||||
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
extractMentions,
|
||||
type MentionType,
|
||||
type MentionSpan,
|
||||
} from '@/lib/mention';
|
||||
import { MentionBadge } from './MentionBadge';
|
||||
import { detectLanguage, getLanguageFromTag } from '@/lib/code-lang-detect';
|
||||
import { detectLinkType, extractUrls, type UnfurlResult } from '@/lib/link-unfurl';
|
||||
import { parseCodeRef, type CodeRef } from '@/lib/code-ref-parser';
|
||||
|
||||
interface ContentRendererProps {
|
||||
content: string;
|
||||
/** Called when a mention is clicked. Provides (type, id, label). */
|
||||
onMentionClick?: (type: MentionType, id: string, label: string) => void;
|
||||
/** Show rich link preview cards for detected URLs */
|
||||
enableLinkPreviews?: boolean;
|
||||
/** Show inline code reference blocks for "file.rs:42" style references */
|
||||
enableCodeRefs?: boolean;
|
||||
/** Additional CSS classes for the wrapper div. */
|
||||
className?: string;
|
||||
/** Base URL for code reference navigation (e.g. /repository/ns/repo) */
|
||||
repoBaseUrl?: string;
|
||||
/** Branch for code reference navigation */
|
||||
branch?: string;
|
||||
}
|
||||
|
||||
export const ContentRenderer = memo(function ContentRenderer({
|
||||
content,
|
||||
onMentionClick,
|
||||
enableLinkPreviews = false,
|
||||
enableCodeRefs = false,
|
||||
className,
|
||||
repoBaseUrl,
|
||||
branch = 'main',
|
||||
}: ContentRendererProps) {
|
||||
const { safeContent, mentions } = useMemo(() => extractMentions(content), [content]);
|
||||
|
||||
// Pre-detect standalone URLs and code refs for inline rendering
|
||||
const { urls, codeRefs } = useMemo(() => {
|
||||
if (!enableLinkPreviews && !enableCodeRefs) return { urls: new Map<string, UnfurlResult>(), codeRefs: [] };
|
||||
|
||||
const urlMap = new Map<string, UnfurlResult>();
|
||||
const refs: CodeRef[] = [];
|
||||
|
||||
// Extract standalone URLs (not inside markdown links)
|
||||
const urlMatches = extractUrls(content);
|
||||
for (const { url } of urlMatches) {
|
||||
const result = detectLinkType(url);
|
||||
if (result && result.type !== 'external') {
|
||||
urlMap.set(url, result);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract code refs
|
||||
if (enableCodeRefs) {
|
||||
const refMatches = content.match(/[^\s:]+:\d+(?:-\d+)?/g) ?? [];
|
||||
for (const match of refMatches) {
|
||||
const parsed = parseCodeRef(match);
|
||||
if (parsed) refs.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
return { urls: urlMap, codeRefs: refs };
|
||||
}, [content, enableLinkPreviews, enableCodeRefs]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'text-[15px] text-foreground',
|
||||
'max-w-full min-w-0 break-words',
|
||||
'[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs',
|
||||
'[&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:overflow-x-auto',
|
||||
'[&_p]:whitespace-pre-wrap [&_p]:leading-[1.4] [&_p]:my-1',
|
||||
'[&_ul]:list-disc [&_ul]:pl-6 [&_ul]:my-1',
|
||||
'[&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:my-1',
|
||||
'[&_li]:my-0.5',
|
||||
'[&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:pl-4 [&_blockquote]:my-1',
|
||||
'[&_h1]:text-xl [&_h1]:font-semibold [&_h1]:my-2',
|
||||
'[&_h2]:text-lg [&_h2]:font-semibold [&_h2]:my-2',
|
||||
'[&_h3]:text-base [&_h3]:font-semibold [&_h3]:my-1.5',
|
||||
'[&_strong]:font-semibold',
|
||||
'[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2',
|
||||
'[&_hr]:border-foreground/20 [&_hr]:my-2',
|
||||
'[&_table]:w-full [&_table]:border-collapse [&_table]:rounded-md [&_table]:border [&_table]:border-foreground/20 [&_table]:my-2',
|
||||
'[&_th]:border [&_th]:border-foreground/20 [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:font-bold',
|
||||
'[&_td]:border [&_td]:border-foreground/20 [&_td]:px-2 [&_td]:py-1 [&_td]:text-left',
|
||||
'[&_tr]:border-t [&_tr]:even:bg-muted',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={buildComponents(mentions, onMentionClick, {
|
||||
enableLinkPreviews,
|
||||
enableCodeRefs,
|
||||
urls,
|
||||
codeRefs,
|
||||
repoBaseUrl,
|
||||
branch,
|
||||
}) as Record<string, unknown>}
|
||||
>
|
||||
{safeContent}
|
||||
</Markdown>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface RendererOptions {
|
||||
enableLinkPreviews: boolean;
|
||||
enableCodeRefs: boolean;
|
||||
urls: Map<string, UnfurlResult>;
|
||||
codeRefs: CodeRef[];
|
||||
repoBaseUrl?: string;
|
||||
branch?: string;
|
||||
}
|
||||
|
||||
function buildComponents(
|
||||
mentions: MentionSpan[],
|
||||
onMentionClick: ContentRendererProps['onMentionClick'],
|
||||
opts: RendererOptions,
|
||||
) {
|
||||
const { repoBaseUrl, branch } = opts;
|
||||
|
||||
return {
|
||||
p: ({ children }: { children: React.ReactNode }) => (
|
||||
<p>{restoreInNode(children, mentions, onMentionClick)}</p>
|
||||
),
|
||||
li: ({ children }: { children: React.ReactNode }) => (
|
||||
<li>{restoreInNode(children, mentions, onMentionClick)}</li>
|
||||
),
|
||||
strong: ({ children }: { children: React.ReactNode }) => (
|
||||
<strong>{restoreInNode(children, mentions, onMentionClick)}</strong>
|
||||
),
|
||||
em: ({ children }: { children: React.ReactNode }) => (
|
||||
<em>{restoreInNode(children, mentions, onMentionClick)}</em>
|
||||
),
|
||||
code: ({ className, children, ...props }: React.ComponentProps<'code'>) => {
|
||||
const isBlock = typeof className === 'string' && className.includes('language-');
|
||||
if (isBlock) {
|
||||
// Enhanced fenced code block — try to detect language
|
||||
const langTag = typeof className === 'string'
|
||||
? className.replace('language-', '')
|
||||
: undefined;
|
||||
const langInfo = langTag
|
||||
? getLanguageFromTag(langTag)
|
||||
: detectLanguage(String(children));
|
||||
const langLabel = langInfo?.label ?? langTag;
|
||||
|
||||
return (
|
||||
<EnhancedCodeBlock
|
||||
code={String(children)}
|
||||
language={langLabel}
|
||||
repoBaseUrl={repoBaseUrl}
|
||||
branch={branch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="font-mono rounded bg-muted px-1 py-0.5 text-xs" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }: React.ComponentProps<'pre'>) => {
|
||||
// Extract language from nested code element
|
||||
const codeEl = children as React.ReactElement<{ className?: string; children?: string }>;
|
||||
const langTag = codeEl?.props?.className?.replace('language-', '');
|
||||
return (
|
||||
<EnhancedCodeBlock
|
||||
code={codeEl?.props?.children ?? ''}
|
||||
language={langTag}
|
||||
repoBaseUrl={repoBaseUrl}
|
||||
branch={branch}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Code block with language badge and optional line numbers */
|
||||
function EnhancedCodeBlock({
|
||||
code,
|
||||
language,
|
||||
repoBaseUrl: _repoBaseUrl,
|
||||
branch: _branch,
|
||||
}: {
|
||||
code: string;
|
||||
language?: string;
|
||||
repoBaseUrl?: string;
|
||||
branch?: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const langInfo = language ? getLanguageFromTag(language) : detectLanguage(code);
|
||||
const displayLang = langInfo?.label ?? language ?? null;
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = code;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted overflow-hidden my-2">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b bg-muted/70">
|
||||
<div className="flex gap-1.5 -ml-1">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-red-500/50" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-yellow-500/50" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-green-500/50" />
|
||||
</div>
|
||||
{displayLang && (
|
||||
<span className="text-xs font-mono bg-muted-foreground/10 text-muted-foreground px-1.5 py-0.5 rounded">
|
||||
{displayLang}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="ml-auto p-1 rounded hover:bg-muted-foreground/10 transition-colors"
|
||||
title={copied ? 'Copied!' : 'Copy'}
|
||||
>
|
||||
{copied ? (
|
||||
<span className="text-xs text-green-600 font-medium">Copied!</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Copy</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{/* Code */}
|
||||
<pre className="p-3 overflow-x-auto font-mono text-sm leading-5 text-foreground/90">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Recursively restore mention placeholders inside text nodes. */
|
||||
function restoreInNode(
|
||||
children: React.ReactNode,
|
||||
mentions: MentionSpan[],
|
||||
onMentionClick?: (type: MentionType, id: string, label: string) => void,
|
||||
): React.ReactNode {
|
||||
if (typeof children === 'string') {
|
||||
return restoreMentionsText(children, mentions, onMentionClick);
|
||||
}
|
||||
if (Array.isArray(children)) {
|
||||
return children.map((child, i) => (
|
||||
<span key={i}>{restoreInNode(child, mentions, onMentionClick)}</span>
|
||||
));
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
/** Restore mention placeholders inside a single text string into React elements. */
|
||||
function restoreMentionsText(
|
||||
text: string,
|
||||
mentions: MentionSpan[],
|
||||
onMentionClick?: (type: MentionType, id: string, label: string) => void,
|
||||
): React.ReactNode {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
const PLACEHOLDER_RE = /MENTION_(\d+)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = PLACEHOLDER_RE.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
const idx = parseInt(match[1], 10);
|
||||
const m = mentions[idx];
|
||||
if (m) {
|
||||
parts.push(
|
||||
<MentionBadge
|
||||
key={`mention-${idx}`}
|
||||
type={m.type}
|
||||
id={m.id}
|
||||
label={m.label}
|
||||
onClick={onMentionClick}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
lastIndex = PLACEHOLDER_RE.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : text;
|
||||
}
|
||||
98
src/components/shared/GlobalNavigationShortcuts.tsx
Normal file
98
src/components/shared/GlobalNavigationShortcuts.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Registers global navigation shortcuts at app startup.
|
||||
* Shortcuts: g n → /notify, g i → /issues, g r → /repos, g m → /rooms
|
||||
* Uses the two-key chord pattern (press g, then the key).
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface ChordTarget {
|
||||
key: string;
|
||||
path: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const CHORD_KEY = 'g';
|
||||
|
||||
const TARGETS: ChordTarget[] = [
|
||||
{ key: 'n', path: '/notify', description: 'Go to Notifications' },
|
||||
{ key: 'i', path: '/issues', description: 'Go to Issues' },
|
||||
{ key: 'r', path: '/repos', description: 'Go to Repositories' },
|
||||
{ key: 'm', path: '/rooms', description: 'Go to Messages' },
|
||||
];
|
||||
|
||||
/** Show a brief indicator when a chord is active */
|
||||
function showChordHint() {
|
||||
const existing = document.getElementById('nav-chord-hint');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.id = 'nav-chord-hint';
|
||||
el.textContent = 'g _';
|
||||
el.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--foreground);
|
||||
color: var(--background);
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
`;
|
||||
document.body.appendChild(el);
|
||||
|
||||
setTimeout(() => {
|
||||
el.style.opacity = '0';
|
||||
setTimeout(() => el.remove(), 300);
|
||||
}, 600);
|
||||
}
|
||||
|
||||
export function GlobalNavigationShortcuts() {
|
||||
const navigate = useNavigate();
|
||||
const pendingKeyRef = useRef<string | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const isEditing =
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable;
|
||||
if (isEditing) return;
|
||||
|
||||
if (pendingKeyRef.current) {
|
||||
const next = e.key.toLowerCase();
|
||||
const match = TARGETS.find((t) => t.key === next);
|
||||
if (match) {
|
||||
e.preventDefault();
|
||||
navigate(match.path);
|
||||
}
|
||||
pendingKeyRef.current = null;
|
||||
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; }
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key.toLowerCase() === CHORD_KEY) {
|
||||
pendingKeyRef.current = CHORD_KEY;
|
||||
showChordHint();
|
||||
timerRef.current = setTimeout(() => { pendingKeyRef.current = null; }, 600);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [navigate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
146
src/components/shared/KeyboardShortcutsSheet.tsx
Normal file
146
src/components/shared/KeyboardShortcutsSheet.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Keyboard shortcuts help sheet — triggered by pressing `?`.
|
||||
* Shows all registered shortcuts from useKeyboardShortcut.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from '@/components/ui/sheet';
|
||||
import { getRegisteredShortcuts, type ShortcutScope } from '@/hooks/useKeyboardShortcut';
|
||||
import { registerGlobalKeyboardListener, unregisterGlobalKeyboardListener } from '@/hooks/useKeyboardShortcut';
|
||||
import {
|
||||
Keyboard,
|
||||
Globe,
|
||||
FileText,
|
||||
Puzzle,
|
||||
} from 'lucide-react';
|
||||
|
||||
const SCOPE_LABELS: Record<ShortcutScope, { label: string; Icon: React.ComponentType<{ className?: string }> }> = {
|
||||
global: { label: 'Global', Icon: Globe },
|
||||
page: { label: 'Page', Icon: FileText },
|
||||
component: { label: 'Component', Icon: Puzzle },
|
||||
};
|
||||
|
||||
export function KeyboardShortcutsSheet() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
registerGlobalKeyboardListener();
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
// Open on `?` when not in an input
|
||||
const target = e.target as HTMLElement;
|
||||
const isEditing =
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable;
|
||||
if (e.key === '?' && !isEditing) {
|
||||
setOpen((v) => !v);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKey);
|
||||
unregisterGlobalKeyboardListener();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const shortcuts = getRegisteredShortcuts();
|
||||
const byScope = shortcuts.reduce<Record<ShortcutScope, typeof shortcuts>>((acc, s) => {
|
||||
(acc[s.scope] ??= []).push(s);
|
||||
return acc;
|
||||
}, {} as Record<ShortcutScope, typeof shortcuts>);
|
||||
|
||||
const scopes: ShortcutScope[] = ['global', 'page', 'component'];
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent side="right" className="w-80 overflow-y-auto">
|
||||
<SheetHeader className="mb-4">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<Keyboard className="h-5 w-5" />
|
||||
Keyboard Shortcuts
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Press{' '}
|
||||
<kbd className="inline-flex items-center rounded border bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||
?
|
||||
</kbd>{' '}
|
||||
to toggle this panel.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{scopes.map((scope) => {
|
||||
const items = byScope[scope];
|
||||
if (!items?.length) return null;
|
||||
const { label, Icon } = SCOPE_LABELS[scope];
|
||||
return (
|
||||
<section key={scope}>
|
||||
<h3 className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{items.map((s, i) => (
|
||||
<div
|
||||
key={`${s.key}-${s.ctrl ?? ''}-${s.meta ?? ''}-${s.shift ?? ''}-${i}`}
|
||||
className="flex items-center justify-between rounded px-2 py-1.5 hover:bg-muted/60"
|
||||
>
|
||||
<span className="text-sm text-foreground">
|
||||
{s.description ?? s.key}
|
||||
</span>
|
||||
<kbd className="flex items-center gap-0.5 font-mono text-xs">
|
||||
{s.meta && (
|
||||
<span className="rounded border bg-muted px-1 py-0.5 text-[10px]">
|
||||
⌘
|
||||
</span>
|
||||
)}
|
||||
{s.ctrl && (
|
||||
<span className="rounded border bg-muted px-1 py-0.5 text-[10px]">
|
||||
Ctrl
|
||||
</span>
|
||||
)}
|
||||
{s.alt && (
|
||||
<span className="rounded border bg-muted px-1 py-0.5 text-[10px]">
|
||||
⌥
|
||||
</span>
|
||||
)}
|
||||
{s.shift && (
|
||||
<span className="rounded border bg-muted px-1 py-0.5 text-[10px]">
|
||||
⇧
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded border bg-muted px-1 py-0.5 text-[10px]">
|
||||
{s.key.toUpperCase()}
|
||||
</span>
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{shortcuts.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No shortcuts registered yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded border border-dashed p-3">
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Cmd+K opens the command palette
|
||||
</p>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
303
src/components/shared/LinkPreview.tsx
Normal file
303
src/components/shared/LinkPreview.tsx
Normal file
@ -0,0 +1,303 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Renders a rich preview card for detected URLs.
|
||||
* Supports: Issue, PR, Commit, Repository, External.
|
||||
*/
|
||||
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
ExternalLink,
|
||||
GitPullRequest,
|
||||
GitCommit,
|
||||
BookOpen,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
GitMerge,
|
||||
ArrowUpRight,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { UnfurlResult } from '@/lib/link-unfurl';
|
||||
|
||||
interface LinkPreviewProps {
|
||||
result: UnfurlResult;
|
||||
/** Called when the preview is clicked */
|
||||
onClick?: (result: UnfurlResult) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ── Issue Preview ─────────────────────────────────────────────────────────────
|
||||
|
||||
function IssuePreview({ result }: { result: UnfurlResult }) {
|
||||
const navigate = useNavigate();
|
||||
const { issueNumber, project } = result.ids;
|
||||
|
||||
const { data: issue, isLoading } = useQuery({
|
||||
queryKey: ['issue-preview', project, issueNumber],
|
||||
queryFn: async () => {
|
||||
const { issueGet } = await import('@/client');
|
||||
const resp = await issueGet({ path: { project: project!, number: issueNumber! } });
|
||||
return resp.data?.data ?? null;
|
||||
},
|
||||
enabled: !!project && !!issueNumber,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const isOpen = issue?.state === 'open';
|
||||
const Icon = isOpen ? AlertCircle : CheckCircle2;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left rounded-lg border bg-card overflow-hidden hover:border-primary/40 transition-colors"
|
||||
onClick={() => navigate(`/project/${project}/issues/${issueNumber}`)}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-3">
|
||||
<div
|
||||
className={cn(
|
||||
'mt-0.5 flex-shrink-0 h-8 w-8 rounded-full border flex items-center justify-center',
|
||||
isOpen ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-600' : 'bg-violet-500/10 border-violet-500/20 text-violet-600',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-0.5">
|
||||
<span className="font-mono text-xs">#{issueNumber}</span>
|
||||
{project && <span>in {project}</span>}
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-4 w-3/4 bg-muted rounded animate-pulse" />
|
||||
<div className="h-3 w-1/4 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
) : issue ? (
|
||||
<>
|
||||
<p className="text-sm font-medium text-foreground leading-tight truncate">
|
||||
{issue.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs px-1.5 py-0.5 rounded font-medium border',
|
||||
isOpen
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-400 dark:border-emerald-800'
|
||||
: 'bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-950/40 dark:text-violet-400 dark:border-violet-800',
|
||||
)}
|
||||
>
|
||||
{isOpen ? 'Open' : 'Closed'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
by {issue.author_username ?? issue.author}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">Issue not found</p>
|
||||
)}
|
||||
</div>
|
||||
<ArrowUpRight className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── PR Preview ─────────────────────────────────────────────────────────────
|
||||
|
||||
function PRPreview({ result }: { result: UnfurlResult }) {
|
||||
const navigate = useNavigate();
|
||||
const { prNumber, namespace, repo } = result.ids;
|
||||
|
||||
const { data: pr, isLoading } = useQuery({
|
||||
queryKey: ['pr-preview', namespace, repo, prNumber],
|
||||
queryFn: async () => {
|
||||
const { pullRequestGet } = await import('@/client');
|
||||
const resp = await pullRequestGet({ path: { namespace: namespace!, repo: repo!, number: prNumber! } });
|
||||
return resp.data?.data ?? null;
|
||||
},
|
||||
enabled: !!namespace && !!repo && !!prNumber,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const status = pr?.status ?? 'open';
|
||||
const StatusIcon =
|
||||
status === 'merged' ? GitMerge : status === 'closed' ? XCircle : GitPullRequest;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left rounded-lg border bg-card overflow-hidden hover:border-primary/40 transition-colors"
|
||||
onClick={() => navigate(`/repository/${namespace}/${repo}/pulls/${prNumber}`)}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-3">
|
||||
<div
|
||||
className={cn(
|
||||
'mt-0.5 flex-shrink-0 h-8 w-8 rounded-full border flex items-center justify-center',
|
||||
status === 'open'
|
||||
? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-600'
|
||||
: status === 'merged'
|
||||
? 'bg-purple-500/10 border-purple-500/20 text-purple-600'
|
||||
: 'bg-red-500/10 border-red-500/20 text-red-600',
|
||||
)}
|
||||
>
|
||||
<StatusIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-0.5">
|
||||
<span className="font-mono text-xs">#{prNumber}</span>
|
||||
{namespace && repo && <span>in {namespace}/{repo}</span>}
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-4 w-3/4 bg-muted rounded animate-pulse" />
|
||||
<div className="h-3 w-1/3 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
) : pr ? (
|
||||
<>
|
||||
<p className="text-sm font-medium text-foreground leading-tight truncate">
|
||||
{pr.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs px-1.5 py-0.5 rounded font-medium border capitalize',
|
||||
status === 'open'
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||
: status === 'merged'
|
||||
? 'bg-purple-50 text-purple-700 border-purple-200'
|
||||
: 'bg-red-50 text-red-700 border-red-200',
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{pr.author_username ?? pr.author}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">PR not found</p>
|
||||
)}
|
||||
</div>
|
||||
<ArrowUpRight className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Commit Preview ─────────────────────────────────────────────────────────
|
||||
|
||||
function CommitPreview({ result }: { result: UnfurlResult }) {
|
||||
const navigate = useNavigate();
|
||||
const { commitOid, namespace, repo } = result.ids;
|
||||
const shortOid = commitOid?.slice(0, 7) ?? 'unknown';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left rounded-lg border bg-card overflow-hidden hover:border-primary/40 transition-colors"
|
||||
onClick={() => navigate(`/repository/${namespace}/${repo}/commit/${commitOid}`)}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-3">
|
||||
<div className="mt-0.5 flex-shrink-0 h-8 w-8 rounded-full border bg-muted flex items-center justify-center text-muted-foreground">
|
||||
<GitCommit className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-muted-foreground mb-0.5">
|
||||
{namespace}/{repo}
|
||||
</div>
|
||||
<p className="text-sm font-mono text-foreground">
|
||||
{shortOid}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowUpRight className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Repository Preview ─────────────────────────────────────────────────────
|
||||
|
||||
function RepoPreview({ result }: { result: UnfurlResult }) {
|
||||
const navigate = useNavigate();
|
||||
const { namespace, repo } = result.ids;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left rounded-lg border bg-card overflow-hidden hover:border-primary/40 transition-colors"
|
||||
onClick={() => navigate(`/repository/${namespace}/${repo}`)}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-3">
|
||||
<div className="mt-0.5 flex-shrink-0 h-8 w-8 rounded-full border bg-muted flex items-center justify-center text-muted-foreground">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-0.5">
|
||||
<span className="font-medium text-foreground">
|
||||
{namespace}/{repo}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Repository</p>
|
||||
</div>
|
||||
<ArrowUpRight className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── External URL Preview ────────────────────────────────────────────────────
|
||||
|
||||
function ExternalPreview({ result }: { result: UnfurlResult }) {
|
||||
return (
|
||||
<a
|
||||
href={result.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full flex items-start gap-3 p-3 rounded-lg border bg-card overflow-hidden hover:border-primary/40 transition-colors"
|
||||
>
|
||||
<div className="mt-0.5 flex-shrink-0 h-8 w-8 rounded-full border bg-muted flex items-center justify-center text-muted-foreground">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-foreground truncate">{result.domain}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{result.url}</p>
|
||||
</div>
|
||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main LinkPreview component ───────────────────────────────────────────────
|
||||
|
||||
export function LinkPreview({ result, className }: LinkPreviewProps) {
|
||||
switch (result.type) {
|
||||
case 'issue':
|
||||
return <IssuePreview result={result} />;
|
||||
case 'pull_request':
|
||||
return <PRPreview result={result} />;
|
||||
case 'commit':
|
||||
return <CommitPreview result={result} />;
|
||||
case 'repository':
|
||||
return <RepoPreview result={result} />;
|
||||
case 'external':
|
||||
return <ExternalPreview result={result} />;
|
||||
default:
|
||||
// Fallback: render as a simple link
|
||||
return (
|
||||
<a
|
||||
href={result.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
'text-primary underline underline-offset-2 text-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{result.url}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
84
src/components/shared/MentionBadge.tsx
Normal file
84
src/components/shared/MentionBadge.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { MentionType } from '@/lib/mention';
|
||||
|
||||
interface MentionBadgeProps {
|
||||
type: MentionType;
|
||||
label: string;
|
||||
onClick?: (type: MentionType, id: string, label: string) => void;
|
||||
id: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TYPE_STYLE: Record<MentionType, { light: string; dark?: string; prefix: string }> = {
|
||||
user: {
|
||||
light: 'bg-blue-50 text-blue-600',
|
||||
dark: 'dark:bg-blue-900/30 dark:text-blue-300',
|
||||
prefix: '@',
|
||||
},
|
||||
channel: {
|
||||
light: 'bg-gray-50 text-gray-600',
|
||||
dark: 'dark:bg-gray-800 dark:text-gray-300',
|
||||
prefix: '#',
|
||||
},
|
||||
ai: {
|
||||
light: 'bg-green-50 text-green-600',
|
||||
dark: 'dark:bg-green-900/30 dark:text-green-300',
|
||||
prefix: '@',
|
||||
},
|
||||
command: {
|
||||
light: 'bg-amber-50 text-amber-600',
|
||||
dark: 'dark:bg-amber-900/30 dark:text-amber-300',
|
||||
prefix: '/',
|
||||
},
|
||||
special_here: {
|
||||
light: 'bg-orange-50 text-orange-600',
|
||||
dark: 'dark:bg-orange-900/30 dark:text-orange-300',
|
||||
prefix: '@',
|
||||
},
|
||||
special_channel: {
|
||||
light: 'bg-orange-50 text-orange-600',
|
||||
dark: 'dark:bg-orange-900/30 dark:text-orange-300',
|
||||
prefix: '@',
|
||||
},
|
||||
};
|
||||
|
||||
export function MentionBadge({ type, label, onClick, id, className }: MentionBadgeProps) {
|
||||
const style = TYPE_STYLE[type] ?? TYPE_STYLE['user'];
|
||||
const isInteractive = !!onClick;
|
||||
|
||||
return (
|
||||
<span
|
||||
role={isInteractive ? 'button' : undefined}
|
||||
tabIndex={isInteractive ? 0 : undefined}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-0.5 rounded px-1 py-0.5 font-medium text-xs mx-0.5',
|
||||
style.light,
|
||||
style.dark,
|
||||
isInteractive && 'cursor-pointer hover:opacity-80 transition-opacity',
|
||||
className,
|
||||
)}
|
||||
onClick={isInteractive ? () => onClick(type, id, label) : undefined}
|
||||
onKeyDown={
|
||||
isInteractive
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick(type, id, label);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{style.prefix}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Get mention style class names for direct use (without the component wrapper). */
|
||||
export function getMentionStyleClasses(type: MentionType): string {
|
||||
const style = TYPE_STYLE[type] ?? TYPE_STYLE['user'];
|
||||
return cn(style.light, style.dark);
|
||||
}
|
||||
218
src/components/shared/MiniChat.tsx
Normal file
218
src/components/shared/MiniChat.tsx
Normal file
@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Compact inline chat component for embedding in file browsers and PR diffs.
|
||||
* Shows recent messages from a room + a text input for quick replies.
|
||||
*
|
||||
* Usage:
|
||||
* <MiniChat roomId="room-123" repoBaseUrl="/repository/ns/repo" branch="main" />
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { RoomWsClient, type RoomWsStatus } from '@/lib/room-ws-client';
|
||||
import { ContentRenderer } from '@/components/shared/ContentRenderer';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Send } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MiniChatProps {
|
||||
/** Room ID to load messages from */
|
||||
roomId: string;
|
||||
/** WebSocket client for this room (optional — falls back to REST) */
|
||||
wsClient?: RoomWsClient | null;
|
||||
/** Base URL for code reference navigation */
|
||||
repoBaseUrl?: string;
|
||||
/** Branch for code references */
|
||||
branch?: string;
|
||||
/** Height of the message list */
|
||||
maxHeight?: number;
|
||||
/** Called when a message mention is clicked */
|
||||
onMentionClick?: (type: string, id: string, label: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
sender_type: string;
|
||||
sender_id?: string | null;
|
||||
sender_name?: string | null;
|
||||
content: string;
|
||||
content_type: string;
|
||||
send_at: string;
|
||||
display_name?: string | null;
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = Math.floor((now.getTime() - d.getTime()) / 1000);
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
export function MiniChat({
|
||||
roomId,
|
||||
wsClient,
|
||||
repoBaseUrl,
|
||||
branch = 'main',
|
||||
maxHeight = 320,
|
||||
onMentionClick,
|
||||
className,
|
||||
}: MiniChatProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [input, setInput] = useState('');
|
||||
const [wsReady, setWsReady] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Ensure WS is connected — use public getStatus() and onStatusChange callback
|
||||
useEffect(() => {
|
||||
if (!wsClient) return;
|
||||
if (wsClient.getStatus() === 'open') { setWsReady(true); return; }
|
||||
const onStatusChange = (status: RoomWsStatus) => setWsReady(status === 'open');
|
||||
wsClient.updateCallbacks({ onStatusChange });
|
||||
return () => {
|
||||
wsClient.updateCallbacks({ onStatusChange: undefined });
|
||||
};
|
||||
}, [wsClient]);
|
||||
|
||||
const { data: messagesData, isLoading } = useQuery({
|
||||
queryKey: ['mini-chat-messages', roomId],
|
||||
queryFn: async () => {
|
||||
if (wsClient && wsReady) {
|
||||
try {
|
||||
return await wsClient.messageListWs(roomId, { limit: 20 });
|
||||
} catch {
|
||||
// fall through to REST
|
||||
}
|
||||
}
|
||||
// REST fallback
|
||||
return wsClient?.messageList(roomId, { limit: 20 }) ?? null;
|
||||
},
|
||||
enabled: true,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const messages: ChatMessage[] = messagesData?.messages ?? [];
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages.length]);
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: async (body: string) => {
|
||||
if (wsClient) {
|
||||
await wsClient.messageCreate(roomId, body, { contentType: 'text' });
|
||||
} else {
|
||||
const { messageCreate } = await import('@/client');
|
||||
await messageCreate({ path: { room_id: roomId }, body: { content: body, content_type: 'text' } });
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
setInput('');
|
||||
queryClient.invalidateQueries({ queryKey: ['mini-chat-messages', roomId] });
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const body = input.trim();
|
||||
if (!body) return;
|
||||
sendMutation.mutate(body);
|
||||
}, [input, sendMutation]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col rounded-lg border bg-card overflow-hidden', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b bg-muted/40">
|
||||
<span className="text-xs font-semibold text-foreground">Chat</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">{roomId}</span>
|
||||
{!wsReady && (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground italic">REST mode</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto"
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-16">
|
||||
<div className="h-4 w-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-20 gap-1">
|
||||
<p className="text-xs text-muted-foreground italic">No messages yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className="px-3 py-2 hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<Avatar className="h-4 w-4">
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{(msg.display_name ?? msg.sender_id ?? '?')[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-[11px] font-medium text-foreground">
|
||||
{msg.display_name ?? msg.sender_id ?? 'Unknown'}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground ml-auto">
|
||||
{formatTime(msg.send_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[12px] text-foreground/80 leading-snug ml-6">
|
||||
<ContentRenderer
|
||||
content={msg.content}
|
||||
onMentionClick={onMentionClick}
|
||||
repoBaseUrl={repoBaseUrl}
|
||||
branch={branch}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="flex items-end gap-1.5 px-3 py-2 border-t bg-muted/20">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Reply in chat…"
|
||||
rows={1}
|
||||
className="flex-1 min-h-[28px] max-h-[80px] resize-none rounded border border-input bg-background px-2 py-1.5 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
style={{ scrollbarWidth: 'thin' }}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || sendMutation.isPending}
|
||||
>
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -58,7 +58,9 @@ function CommandDialog({
|
||||
)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user