From 7620f2f2814e722443f16ec567f934670026bd79 Mon Sep 17 00:00:00 2001
From: ZhenYi <434836402@qq.com>
Date: Sat, 25 Apr 2026 09:53:12 +0800
Subject: [PATCH] feat(command): use real API data for navigation, fix
notification button
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
src/components/layout/sidebar-user.tsx | 29 +-
src/components/shared/CodeBlock.tsx | 155 +++++++++
src/components/shared/CodeReference.tsx | 154 +++++++++
src/components/shared/CommandPalette.tsx | 277 +++++++++++++++
src/components/shared/ContentRenderer.tsx | 323 ++++++++++++++++++
.../shared/GlobalNavigationShortcuts.tsx | 98 ++++++
.../shared/KeyboardShortcutsSheet.tsx | 146 ++++++++
src/components/shared/LinkPreview.tsx | 303 ++++++++++++++++
src/components/shared/MentionBadge.tsx | 84 +++++
src/components/shared/MiniChat.tsx | 218 ++++++++++++
src/components/ui/command.tsx | 4 +-
11 files changed, 1776 insertions(+), 15 deletions(-)
create mode 100644 src/components/shared/CodeBlock.tsx
create mode 100644 src/components/shared/CodeReference.tsx
create mode 100644 src/components/shared/CommandPalette.tsx
create mode 100644 src/components/shared/ContentRenderer.tsx
create mode 100644 src/components/shared/GlobalNavigationShortcuts.tsx
create mode 100644 src/components/shared/KeyboardShortcutsSheet.tsx
create mode 100644 src/components/shared/LinkPreview.tsx
create mode 100644 src/components/shared/MentionBadge.tsx
create mode 100644 src/components/shared/MiniChat.tsx
diff --git a/src/components/layout/sidebar-user.tsx b/src/components/layout/sidebar-user.tsx
index cdb1e21..8fd6a08 100644
--- a/src/components/layout/sidebar-user.tsx
+++ b/src/components/layout/sidebar-user.tsx
@@ -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 && Invitations}
-
+ {!collapsed ? (
+
+ ) : (
+
+
+
+ )}
{user && (
diff --git a/src/components/shared/CodeBlock.tsx b/src/components/shared/CodeBlock.tsx
new file mode 100644
index 0000000..0db4c14
--- /dev/null
+++ b/src/components/shared/CodeBlock.tsx
@@ -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 (
+
+ {/* Header bar */}
+
+ {/* Traffic light dots (macOS style) */}
+
+
+ {filename && (
+
+ {filename}
+
+ )}
+
+
+ {displayLang && (
+
+ {displayLang}
+
+ )}
+
+ {showLineNumbers && (
+
+ )}
+
+
+
+
+
+ {/* Code content */}
+
+
+ {showLineNumbers ? (
+
+
+ {lines.map((line, i) => (
+
+ |
+ {i + 1}
+ |
+ {line} |
+
+ ))}
+
+
+ ) : (
+ children
+ )}
+
+
+
+ );
+}
diff --git a/src/components/shared/CodeReference.tsx b/src/components/shared/CodeReference.tsx
new file mode 100644
index 0000000..82edffa
--- /dev/null
+++ b/src/components/shared/CodeReference.tsx
@@ -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;
+ /** 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(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 (
+
+ {/* Header bar */}
+
+
+ {/* Code preview (optional) */}
+ {getLineContent && (
+ <>
+
+
+ {expanded && (
+
+ {loading ? (
+
+ {Array.from({ length: Math.min(ref.endLine - ref.startLine + 1, 5) }).map((_, i) => (
+
+ ))}
+
+ ) : lines && lines.length > 0 ? (
+
+ {lines.map((line, i) => {
+ const lineNum = ref.startLine + i;
+ return (
+
+
+ {lineNum}
+
+
+ {line || ' '}
+
+
+ );
+ })}
+
+ ) : (
+
+ No content available
+
+ )}
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/src/components/shared/CommandPalette.tsx b/src/components/shared/CommandPalette.tsx
new file mode 100644
index 0000000..f24659d
--- /dev/null
+++ b/src/components/shared/CommandPalette.tsx
@@ -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> = {
+ 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): 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 = {};
+ 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 = {};
+ 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();
+ for (const cmd of allCommands) {
+ const g = groups.get(cmd.group) ?? [];
+ g.push(cmd);
+ groups.set(cmd.group, g);
+ }
+ return groups;
+ }, [allCommands]);
+
+ return (
+
+
+
+ No results found.
+ {Array.from(grouped.entries()).map(([group, cmds]) => (
+
+ {cmds.map((cmd) => {
+ const Icon = iconMap[(cmd as PaletteItem).icon ?? ''] ?? Search;
+ const shortcut = cmd.shortcut;
+ return (
+ handleSelect(cmd.action)}
+ >
+
+ {cmd.label}
+ {shortcut && (
+ {formatShortcut(shortcut)}
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/shared/ContentRenderer.tsx b/src/components/shared/ContentRenderer.tsx
new file mode 100644
index 0000000..ee1a7c8
--- /dev/null
+++ b/src/components/shared/ContentRenderer.tsx
@@ -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(), codeRefs: [] };
+
+ const urlMap = new Map();
+ 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 (
+
+ }
+ >
+ {safeContent}
+
+
+ );
+});
+
+interface RendererOptions {
+ enableLinkPreviews: boolean;
+ enableCodeRefs: boolean;
+ urls: Map;
+ 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 }) => (
+ {restoreInNode(children, mentions, onMentionClick)}
+ ),
+ li: ({ children }: { children: React.ReactNode }) => (
+ {restoreInNode(children, mentions, onMentionClick)}
+ ),
+ strong: ({ children }: { children: React.ReactNode }) => (
+ {restoreInNode(children, mentions, onMentionClick)}
+ ),
+ em: ({ children }: { children: React.ReactNode }) => (
+ {restoreInNode(children, mentions, onMentionClick)}
+ ),
+ 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 (
+
+ );
+ }
+ return (
+
+ {children}
+
+ );
+ },
+ 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 (
+
+ );
+ },
+ };
+}
+
+/** 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 (
+
+ {/* Header */}
+
+
+ {displayLang && (
+
+ {displayLang}
+
+ )}
+
+
+ {/* Code */}
+
+ {code}
+
+
+ );
+}
+
+// ── 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) => (
+ {restoreInNode(child, mentions, onMentionClick)}
+ ));
+ }
+ 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(
+ ,
+ );
+ }
+ lastIndex = PLACEHOLDER_RE.lastIndex;
+ }
+
+ if (lastIndex < text.length) {
+ parts.push(text.slice(lastIndex));
+ }
+
+ return parts.length > 0 ? parts : text;
+}
diff --git a/src/components/shared/GlobalNavigationShortcuts.tsx b/src/components/shared/GlobalNavigationShortcuts.tsx
new file mode 100644
index 0000000..e440a4a
--- /dev/null
+++ b/src/components/shared/GlobalNavigationShortcuts.tsx
@@ -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(null);
+ const timerRef = useRef | 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;
+}
diff --git a/src/components/shared/KeyboardShortcutsSheet.tsx b/src/components/shared/KeyboardShortcutsSheet.tsx
new file mode 100644
index 0000000..eefd4ce
--- /dev/null
+++ b/src/components/shared/KeyboardShortcutsSheet.tsx
@@ -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 }> = {
+ 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>((acc, s) => {
+ (acc[s.scope] ??= []).push(s);
+ return acc;
+ }, {} as Record);
+
+ const scopes: ShortcutScope[] = ['global', 'page', 'component'];
+
+ return (
+
+
+
+
+
+ Keyboard Shortcuts
+
+
+ Press{' '}
+
+ ?
+ {' '}
+ to toggle this panel.
+
+
+
+
+ {scopes.map((scope) => {
+ const items = byScope[scope];
+ if (!items?.length) return null;
+ const { label, Icon } = SCOPE_LABELS[scope];
+ return (
+
+
+
+ {label}
+
+
+ {items.map((s, i) => (
+
+
+ {s.description ?? s.key}
+
+
+ {s.meta && (
+
+ ⌘
+
+ )}
+ {s.ctrl && (
+
+ Ctrl
+
+ )}
+ {s.alt && (
+
+ ⌥
+
+ )}
+ {s.shift && (
+
+ ⇧
+
+ )}
+
+ {s.key.toUpperCase()}
+
+
+
+ ))}
+
+
+ );
+ })}
+
+ {shortcuts.length === 0 && (
+
+ No shortcuts registered yet.
+
+ )}
+
+
+
+
+ Cmd+K opens the command palette
+
+
+
+
+ );
+}
diff --git a/src/components/shared/LinkPreview.tsx b/src/components/shared/LinkPreview.tsx
new file mode 100644
index 0000000..6a00caa
--- /dev/null
+++ b/src/components/shared/LinkPreview.tsx
@@ -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 (
+
+ );
+}
+
+// ── 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 (
+
+ );
+}
+
+// ── Commit Preview ─────────────────────────────────────────────────────────
+
+function CommitPreview({ result }: { result: UnfurlResult }) {
+ const navigate = useNavigate();
+ const { commitOid, namespace, repo } = result.ids;
+ const shortOid = commitOid?.slice(0, 7) ?? 'unknown';
+
+ return (
+
+ );
+}
+
+// ── Repository Preview ─────────────────────────────────────────────────────
+
+function RepoPreview({ result }: { result: UnfurlResult }) {
+ const navigate = useNavigate();
+ const { namespace, repo } = result.ids;
+
+ return (
+
+ );
+}
+
+// ── External URL Preview ────────────────────────────────────────────────────
+
+function ExternalPreview({ result }: { result: UnfurlResult }) {
+ return (
+
+
+
+
+
+
{result.domain}
+
{result.url}
+
+
+
+ );
+}
+
+// ── Main LinkPreview component ───────────────────────────────────────────────
+
+export function LinkPreview({ result, className }: LinkPreviewProps) {
+ switch (result.type) {
+ case 'issue':
+ return ;
+ case 'pull_request':
+ return ;
+ case 'commit':
+ return ;
+ case 'repository':
+ return ;
+ case 'external':
+ return ;
+ default:
+ // Fallback: render as a simple link
+ return (
+
+ {result.url}
+
+ );
+ }
+}
diff --git a/src/components/shared/MentionBadge.tsx b/src/components/shared/MentionBadge.tsx
new file mode 100644
index 0000000..f60bc38
--- /dev/null
+++ b/src/components/shared/MentionBadge.tsx
@@ -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 = {
+ 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 (
+ onClick(type, id, label) : undefined}
+ onKeyDown={
+ isInteractive
+ ? (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onClick(type, id, label);
+ }
+ }
+ : undefined
+ }
+ >
+ {style.prefix}
+ {label}
+
+ );
+}
+
+/** 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);
+}
diff --git a/src/components/shared/MiniChat.tsx b/src/components/shared/MiniChat.tsx
new file mode 100644
index 0000000..bc0600b
--- /dev/null
+++ b/src/components/shared/MiniChat.tsx
@@ -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:
+ *
+ */
+
+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(null);
+ const inputRef = useRef(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) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSend();
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
+ Chat
+ {roomId}
+ {!wsReady && (
+ REST mode
+ )}
+
+
+ {/* Messages */}
+
+ {isLoading ? (
+
+ ) : messages.length === 0 ? (
+
+ ) : (
+
+ {messages.map((msg) => (
+
+
+
+
+ {(msg.display_name ?? msg.sender_id ?? '?')[0]?.toUpperCase()}
+
+
+
+ {msg.display_name ?? msg.sender_id ?? 'Unknown'}
+
+
+ {formatTime(msg.send_at)}
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
+ {/* Input */}
+
+
+
+ );
+}
diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx
index d5c5af6..cdbe520 100644
--- a/src/components/ui/command.tsx
+++ b/src/components/ui/command.tsx
@@ -58,7 +58,9 @@ function CommandDialog({
)}
showCloseButton={showCloseButton}
>
- {children}
+
+ {children}
+
)