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:
ZhenYi 2026-04-25 09:53:12 +08:00
parent 616c0c0e88
commit 7620f2f281
11 changed files with 1776 additions and 15 deletions

View File

@ -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')}
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>
)}
</span>
{!collapsed && <span className="text-sm leading-none">Notify</span>}
</button>
{!collapsed ? (
<button type="button" className={cn(btnClass, 'px-2')}
onClick={() => navigate('/notify')}>
<span className="flex h-6 items-center shrink-0 w-6">
<Bell className="h-4 w-4"/>
</span>
<span className="text-sm leading-none">Notify</span>
</button>
) : (
<div className="flex justify-center">
<NotificationDrawer />
</div>
)}
{user && (
<DropdownMenu>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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;
}

View 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>
);
}

View 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>
);
}
}

View 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);
}

View 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>
);
}

View File

@ -58,7 +58,9 @@ function CommandDialog({
)}
showCloseButton={showCloseButton}
>
{children}
<Command className="flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground">
{children}
</Command>
</DialogContent>
</Dialog>
)