refactor: update frontend components and pages

This commit is contained in:
zhenyi 2026-06-01 22:04:53 +08:00
parent 10baa7fbd2
commit 9bc0e742bc
52 changed files with 2503 additions and 385 deletions

View File

@ -1,69 +1,86 @@
import { useEffect } from "react"; import { useEffect, lazy, useRef } from "react";
import { Navigate, createBrowserRouter, useParams } from "react-router"; import { Navigate, createBrowserRouter, useParams } from "react-router";
import { RouterProvider } from "react-router/dom"; import { RouterProvider } from "react-router/dom";
import { getSavedThemeId, getThemeById, applyTheme, defaultThemeId } from "@/lib/theme"; import { getSavedThemeId, getThemeById, applyTheme, defaultThemeId } from "@/lib/theme";
import { pushFaroMeasurement } from "@/faro";
// Eager imports — app shell (always needed)
import RootLayout from "@/app/root-layout"; import RootLayout from "@/app/root-layout";
import AuthLayout from "@/page/auth/layout";
import LoginPage from "@/page/auth/login";
import RegisterPage from "@/page/auth/register";
import ForgotPasswordPage from "@/page/auth/forgot-password";
import ResetPasswordPage from "@/page/auth/reset-password";
import { PersonalShell, WorkspaceShell, SettingsShell } from "@/components/shell/navshell"; import { PersonalShell, WorkspaceShell, SettingsShell } from "@/components/shell/navshell";
import WorkspaceRepositoriesPage from "@/page/workspace/repositories";
import WorkspaceIssuesPage from "@/page/workspace/issues";
import IssueDetailPage from "@/page/workspace/issues/detail";
import RepoLayout, { RepoIndexRedirect } from "@/page/workspace/repo/layout";
import CodeTab from "@/page/workspace/repo/code";
import CommitsTab from "@/page/workspace/repo/commits";
import BranchesTab from "@/page/workspace/repo/branches";
import TagsTab from "@/page/workspace/repo/tags";
import PullsTab from "@/page/workspace/repo/pulls";
import ContributorsTab from "@/page/workspace/repo/contributors";
import WebhooksTab from "@/page/workspace/repo/webhooks";
import RepoSettingsTab from "@/page/workspace/repo/settings";
import ReadmePage from "@/page/workspace/repo/readme-page";
import CommitDetailPage from "@/page/workspace/repo/commit-detail";
import PullCreatePage from "@/page/workspace/repo/pull-create";
import PullDetailPage from "@/page/workspace/repo/pull-detail";
import WorkspaceSettingsPage from "@/page/workspace/settings";
import ChannelPage from "@/page/workspace/channel";
import WorkplanChatList from "@/page/workspace/workplan/chat-list";
import WorkplanChatConversation from "@/page/workspace/workplan/chat-conversation";
import MeOverviewPage from "@/page/me/overview";
import MeReposPage from "@/page/me/repos";
import MeChatListPage from "@/page/me/chat-list.tsx";
import MeChatConversationPage from "@/page/me/chat-conversation.tsx";
import MeNotificationsPage from "@/page/me/notifications";
import MeStarsPage from "@/page/me/stars";
import MeFollowingPage from "@/page/me/following";
import SettingsProfilePage from "@/page/settings/profile";
import SettingsAppearancePage from "@/page/settings/appearance";
import SettingsNotificationsPage from "@/page/settings/notifications";
import SettingsPrivacyPage from "@/page/settings/privacy";
import SettingsAccessibilityPage from "@/page/settings/accessibility";
import SettingsSecurityPage from "@/page/settings/security";
import SettingsTokensPage from "@/page/settings/tokens";
import SettingsSshKeysPage from "@/page/settings/ssh-keys";
import LandingLayout from "@/page/landing/layout";
import LandingHome from "@/page/landing/home";
import FeaturesPage from "@/page/landing/features";
import WorkflowPage from "@/page/landing/workflow";
import PricingPage from "@/page/landing/pricing";
// Lazy-loaded pages — code-split by route
const AuthLayout = lazy(() => import("@/page/auth/layout"));
const LoginPage = lazy(() => import("@/page/auth/login"));
const RegisterPage = lazy(() => import("@/page/auth/register"));
const ForgotPasswordPage = lazy(() => import("@/page/auth/forgot-password"));
const ResetPasswordPage = lazy(() => import("@/page/auth/reset-password"));
const WorkspaceRepositoriesPage = lazy(() => import("@/page/workspace/repositories"));
const WorkspaceIssuesPage = lazy(() => import("@/page/workspace/issues"));
const IssueDetailPage = lazy(() => import("@/page/workspace/issues/detail"));
const RepoLayout = lazy(() => import("@/page/workspace/repo/layout"));
const RepoIndexRedirect = lazy(() =>
import("@/page/workspace/repo/layout").then((m) => ({ default: m.RepoIndexRedirect })),
);
const CodeTab = lazy(() => import("@/page/workspace/repo/code"));
const CommitsTab = lazy(() => import("@/page/workspace/repo/commits"));
const BranchesTab = lazy(() => import("@/page/workspace/repo/branches"));
const TagsTab = lazy(() => import("@/page/workspace/repo/tags"));
const PullsTab = lazy(() => import("@/page/workspace/repo/pulls"));
const ContributorsTab = lazy(() => import("@/page/workspace/repo/contributors"));
const WebhooksTab = lazy(() => import("@/page/workspace/repo/webhooks"));
const RepoSettingsTab = lazy(() => import("@/page/workspace/repo/settings"));
const ReadmePage = lazy(() => import("@/page/workspace/repo/readme-page"));
const CommitDetailPage = lazy(() => import("@/page/workspace/repo/commit-detail"));
const PullCreatePage = lazy(() => import("@/page/workspace/repo/pull-create"));
const PullDetailPage = lazy(() => import("@/page/workspace/repo/pull-detail"));
const WorkspaceSettingsPage = lazy(() => import("@/page/workspace/settings"));
const ChannelPage = lazy(() => import("@/page/workspace/channel"));
const WorkplanChatList = lazy(() => import("@/page/workspace/workplan/chat-list"));
const WorkplanChatConversation = lazy(() => import("@/page/workspace/workplan/chat-conversation"));
const MeOverviewPage = lazy(() => import("@/page/me/overview"));
const MeReposPage = lazy(() => import("@/page/me/repos"));
const MeChatListPage = lazy(() => import("@/page/me/chat-list"));
const MeChatConversationPage = lazy(() => import("@/page/me/chat-conversation"));
const MeNotificationsPage = lazy(() => import("@/page/me/notifications"));
const MeStarsPage = lazy(() => import("@/page/me/stars"));
const MeFollowingPage = lazy(() => import("@/page/me/following"));
const SettingsProfilePage = lazy(() => import("@/page/settings/profile"));
const SettingsAppearancePage = lazy(() => import("@/page/settings/appearance"));
const SettingsNotificationsPage = lazy(() => import("@/page/settings/notifications"));
const SettingsPrivacyPage = lazy(() => import("@/page/settings/privacy"));
const SettingsAccessibilityPage = lazy(() => import("@/page/settings/accessibility"));
const SettingsSecurityPage = lazy(() => import("@/page/settings/security"));
const SettingsTokensPage = lazy(() => import("@/page/settings/tokens"));
const SettingsSshKeysPage = lazy(() => import("@/page/settings/ssh-keys"));
const LandingLayout = lazy(() => import("@/page/landing/layout"));
const LandingHome = lazy(() => import("@/page/landing/home"));
const FeaturesPage = lazy(() => import("@/page/landing/features"));
const WorkflowPage = lazy(() => import("@/page/landing/workflow"));
const PricingPage = lazy(() => import("@/page/landing/pricing"));
const JoinInvitePage = lazy(() => import("@/page/join-invite"));
const WorkspaceJoinApplyPage = lazy(() => import("@/page/workspace/join-apply"));
/** Moved to module level — avoids re-creating on every App render. */
function WorkspaceCompatRedirect() { function WorkspaceCompatRedirect() {
const { projectName = "" } = useParams(); const { projectName = "" } = useParams();
return <Navigate replace to={`/${projectName}/repos`} />; return <Navigate replace to={`/${projectName}/repos`} />;
} }
function App() { function App() {
const mountRef = useRef(performance.now());
useEffect(() => { useEffect(() => {
const id = getSavedThemeId(); const id = getSavedThemeId();
const preset = getThemeById(id) || getThemeById(defaultThemeId)!; const preset = getThemeById(id) || getThemeById(defaultThemeId)!;
applyTheme(preset); applyTheme(preset);
}, []); }, []);
useEffect(() => {
pushFaroMeasurement('app_render', {
timeToInteractiveMs: Math.round(performance.now() - mountRef.current),
});
}, []);
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
element: <RootLayout />, element: <RootLayout />,
@ -89,6 +106,14 @@ function App() {
}, },
], ],
}, },
{
path: "/join/:code",
element: <JoinInvitePage />,
},
{
path: "/:projectName/join",
element: <WorkspaceJoinApplyPage />,
},
{ {
path: "/workspace/:projectName/*", path: "/workspace/:projectName/*",
element: <WorkspaceCompatRedirect />, element: <WorkspaceCompatRedirect />,

View File

@ -1,10 +1,28 @@
import { Suspense } from "react";
import { Outlet } from "react-router"; import { Outlet } from "react-router";
import { SettingsModalProvider } from "@/components/settings/SettingsModalContext"; import { SettingsModalProvider } from "@/components/settings/SettingsModalContext";
import { ErrorBoundary } from "@/components/ErrorBoundary";
/** Minimal skeleton shown while lazy route chunks load. */
function RouteFallback() {
return (
<div className="flex h-svh items-center justify-center bg-background">
<div className="flex items-center gap-3">
<div className="size-6 animate-spin rounded-full border-2 border-primary/30 border-t-primary" />
<span className="text-sm text-muted-foreground">Loading</span>
</div>
</div>
);
}
export default function RootLayout() { export default function RootLayout() {
return ( return (
<ErrorBoundary>
<SettingsModalProvider> <SettingsModalProvider>
<Suspense fallback={<RouteFallback />}>
<Outlet /> <Outlet />
</Suspense>
</SettingsModalProvider> </SettingsModalProvider>
</ErrorBoundary>
); );
} }

View File

@ -135,26 +135,39 @@ export function CommandPalette() {
}, [open]); }, [open]);
// Fetch data // Fetch data
const fetchData = useCallback(async (q: string) => { const fetchData = useCallback(async (q: string, signal: AbortSignal) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const { data } = await api.get<SearchResponse>("/api/v1/search", { const { data } = await api.get<SearchResponse>("/api/v1/search", {
params: q.trim() ? { q: q.trim() } : {}, params: q.trim() ? { q: q.trim() } : {},
signal,
}); });
setItems(buildHits(data)); if (!signal.aborted) setItems(buildHits(data));
} catch (err) { } catch (err) {
if (signal.aborted) return;
setError(err instanceof Error ? err.message : "Search failed"); setError(err instanceof Error ? err.message : "Search failed");
setItems([]); setItems([]);
} finally {
if (!signal.aborted) setLoading(false);
} }
setLoading(false);
}, []); }, []);
// Pre-fetch on open, debounced re-fetch on type // Debounced re-fetch on type — only when search is non-empty
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const timer = setTimeout(() => fetchData(search), search ? 150 : 0); if (!search.trim()) {
return () => clearTimeout(timer); setItems([]);
setError(null);
setLoading(false);
return;
}
const controller = new AbortController();
const timer = setTimeout(() => fetchData(search, controller.signal), 150);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [open, search, fetchData]); }, [open, search, fetchData]);
// Reset on close // Reset on close
@ -252,7 +265,7 @@ export function CommandPalette() {
<p className="text-sm text-muted-foreground">{error}</p> <p className="text-sm text-muted-foreground">{error}</p>
<button <button
className="mt-1 rounded-md px-3 py-1 text-xs text-primary transition-colors hover:bg-primary/10" className="mt-1 rounded-md px-3 py-1 text-xs text-primary transition-colors hover:bg-primary/10"
onClick={() => fetchData(search)} onClick={() => fetchData(search, new AbortController().signal)}
type="button" type="button"
> >
Retry Retry

View File

@ -0,0 +1,71 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { faro } from '@/faro';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
// Report to Faro with component stack
faro.api.pushError(error, {
context: {
componentStack: info.componentStack ?? '',
reactErrorBoundary: 'true',
},
});
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex h-svh items-center justify-center bg-background p-8">
<div className="max-w-md text-center">
<h1 className="text-lg font-semibold text-foreground">
Something went wrong
</h1>
<p className="mt-2 text-sm text-muted-foreground">
An unexpected error occurred. Please try refreshing the page.
</p>
{this.state.error && (
<pre className="mt-4 max-h-32 overflow-auto rounded-md bg-muted p-3 text-left text-xs text-muted-foreground">
{this.state.error.message}
</pre>
)}
<button
onClick={this.handleReset}
className="mt-6 inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Try again
</button>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@ -7,6 +7,8 @@ interface Props {
content: string; content: string;
/** Ref to the underlying textarea for scroll sync. */ /** Ref to the underlying textarea for scroll sync. */
textareaRef: React.RefObject<HTMLTextAreaElement | null>; textareaRef: React.RefObject<HTMLTextAreaElement | null>;
/** Optional className to match the textarea's padding/font. */
className?: string;
} }
/** /**
@ -19,6 +21,7 @@ interface Props {
export const MentionTextareaOverlay = memo(function MentionTextareaOverlay({ export const MentionTextareaOverlay = memo(function MentionTextareaOverlay({
content, content,
textareaRef, textareaRef,
className = "",
}: Props) { }: Props) {
const overlayRef = useRef<HTMLDivElement>(null); const overlayRef = useRef<HTMLDivElement>(null);
@ -50,7 +53,7 @@ export const MentionTextareaOverlay = memo(function MentionTextareaOverlay({
<div <div
ref={overlayRef} ref={overlayRef}
aria-hidden aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 z-0 overflow-hidden whitespace-pre-wrap break-words px-2.5 py-2 text-sm leading-relaxed [&_span]:align-baseline" className={`pointer-events-none absolute inset-x-0 top-0 z-0 overflow-hidden whitespace-pre-wrap break-words px-2.5 py-2 text-sm leading-relaxed [&_span]:align-baseline ${className}`}
> >
{segments.map((seg, i) => { {segments.map((seg, i) => {
if (seg.type === "mention" && seg.mention) { if (seg.type === "mention" && seg.mention) {

View File

@ -1,4 +1,4 @@
import { useState } from "react"; import { useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { client } from "@/client"; import { client } from "@/client";
import { import {
@ -6,6 +6,7 @@ import {
GitBranch, FileText, GitCommitHorizontal, Tag, Users, GitBranch, FileText, GitCommitHorizontal, Tag, Users,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { sanitizeHtml } from "@/lib/sanitize-html";
import CodePanel from "./code-panel"; import CodePanel from "./code-panel";
import CommitsPanel from "./commits-panel"; import CommitsPanel from "./commits-panel";
import BranchesPanel from "./branches-panel"; import BranchesPanel from "./branches-panel";
@ -76,6 +77,10 @@ export default function RepoView({ workspace, repo }: Props) {
}); });
const [activeTab, setActiveTab] = useState(readme?.html ? "readme" : "code"); const [activeTab, setActiveTab] = useState(readme?.html ? "readme" : "code");
const safeReadmeHtml = useMemo(
() => sanitizeHtml(readme?.html ?? ""),
[readme?.html],
);
if (isLoading) { if (isLoading) {
return ( return (
@ -98,7 +103,7 @@ export default function RepoView({ workspace, repo }: Props) {
} }
const tabs = [ const tabs = [
...(readme?.html ? [{ id: "readme", label: "README", icon: <FileText className="size-3.5" /> }] : []), ...(safeReadmeHtml ? [{ id: "readme", label: "README", icon: <FileText className="size-3.5" /> }] : []),
{ id: "code", label: "Code", icon: <FileText className="size-3.5" /> }, { id: "code", label: "Code", icon: <FileText className="size-3.5" /> },
{ id: "commits", label: "Commits", icon: <GitCommitHorizontal className="size-3.5" /> }, { id: "commits", label: "Commits", icon: <GitCommitHorizontal className="size-3.5" /> },
{ id: "branches", label: "Branches", icon: <GitBranch className="size-3.5" /> }, { id: "branches", label: "Branches", icon: <GitBranch className="size-3.5" /> },
@ -161,8 +166,8 @@ export default function RepoView({ workspace, repo }: Props) {
{/* Tab content */} {/* Tab content */}
<div className="mt-6"> <div className="mt-6">
{activeTab === "code" && <CodePanel workspace={workspace} repo={repo} />} {activeTab === "code" && <CodePanel workspace={workspace} repo={repo} />}
{activeTab === "readme" && readme?.html && ( {activeTab === "readme" && safeReadmeHtml && (
<div className="prose prose-sm dark:prose-invert max-w-none" dangerouslySetInnerHTML={{ __html: readme.html }} /> <div className="prose prose-sm dark:prose-invert max-w-none" dangerouslySetInnerHTML={{ __html: safeReadmeHtml }} />
)} )}
{activeTab === "commits" && <CommitsPanel workspace={workspace} repo={repo} />} {activeTab === "commits" && <CommitsPanel workspace={workspace} repo={repo} />}
{activeTab === "branches" && <BranchesPanel workspace={workspace} repo={repo} />} {activeTab === "branches" && <BranchesPanel workspace={workspace} repo={repo} />}

View File

@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect, lazy, Suspense } from "react";
import { useNavigate, useLocation } from "react-router"; import { useNavigate, useLocation } from "react-router";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { import {
@ -14,14 +14,16 @@ import {
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import SettingsProfilePage from "@/page/settings/profile";
import SettingsSecurityPage from "@/page/settings/security"; // Lazy-loaded settings sections — avoid bundling all settings into the modal chunk
import SettingsAppearancePage from "@/page/settings/appearance"; const SettingsProfilePage = lazy(() => import("@/page/settings/profile"));
import SettingsNotificationsPage from "@/page/settings/notifications"; const SettingsSecurityPage = lazy(() => import("@/page/settings/security"));
import SettingsPrivacyPage from "@/page/settings/privacy"; const SettingsAppearancePage = lazy(() => import("@/page/settings/appearance"));
import SettingsAccessibilityPage from "@/page/settings/accessibility"; const SettingsNotificationsPage = lazy(() => import("@/page/settings/notifications"));
import SettingsTokensPage from "@/page/settings/tokens"; const SettingsPrivacyPage = lazy(() => import("@/page/settings/privacy"));
import SettingsSshKeysPage from "@/page/settings/ssh-keys"; const SettingsAccessibilityPage = lazy(() => import("@/page/settings/accessibility"));
const SettingsTokensPage = lazy(() => import("@/page/settings/tokens"));
const SettingsSshKeysPage = lazy(() => import("@/page/settings/ssh-keys"));
type SectionKey = type SectionKey =
| "profile" | "profile"
@ -239,7 +241,9 @@ export function SettingsModal({ open, onClose, initialSection }: SettingsModalPr
{/* Scrollable content */} {/* Scrollable content */}
<div className="flex-1 overflow-y-auto overscroll-contain"> <div className="flex-1 overflow-y-auto overscroll-contain">
<div className="mx-auto max-w-[660px] px-10 py-8"> <div className="mx-auto max-w-[660px] px-10 py-8">
<Suspense fallback={<div className="space-y-4">{Array.from({ length: 5 }).map((_, i) => <div key={i} className="h-6 animate-pulse rounded bg-muted/50" />)}</div>}>
<ActiveComponent /> <ActiveComponent />
</Suspense>
</div> </div>
</div> </div>
</div> </div>

View File

@ -37,7 +37,7 @@ function PersonalAvatar() {
)} )}
> >
{me?.avatar_url ? ( {me?.avatar_url ? (
<img alt="" className="size-full object-cover" src={me.avatar_url} /> <img alt={name + " 的头像"} className="size-full object-cover" src={me.avatar_url} />
) : ( ) : (
workspaceInitial(name) workspaceInitial(name)
)} )}

View File

@ -47,7 +47,7 @@ function WorkspaceIcon({
> >
{workspace.avatar_url ? ( {workspace.avatar_url ? (
<img <img
alt="" alt={workspace.name + " 的头像"}
className="size-full object-cover" className="size-full object-cover"
src={workspace.avatar_url} src={workspace.avatar_url}
/> />

View File

@ -14,7 +14,7 @@ export function WorkspaceAvatar({ workspace }: { workspace?: WorkspaceResponse }
)} )}
> >
{workspace?.avatar_url ? ( {workspace?.avatar_url ? (
<img alt="" className="size-full object-cover" src={workspace.avatar_url} /> <img alt={name + " 的头像"} className="size-full object-cover" src={workspace.avatar_url} />
) : ( ) : (
workspaceInitial(name) workspaceInitial(name)
)} )}

View File

@ -12,15 +12,18 @@ import {
Plus, Plus,
Search, Search,
Settings, Settings,
UserPlus,
Users, Users,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, client } from "@/client"; import { api, client } from "@/client";
import { useAuth } from "@/context/auth-context";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CommandPalette } from "@/components/CommandPalette"; import { CommandPalette } from "@/components/CommandPalette";
import RoomCreateDialog from "@/page/workspace/channel/room-create-dialog"; import RoomCreateDialog from "@/page/workspace/channel/room-create-dialog";
import InviteDialog from "@/page/workspace/channel/invite-dialog";
import NavShell from "./rail"; import NavShell from "./rail";
import { ShellNavLink } from "./shared"; import { ShellNavLink } from "./shared";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -386,6 +389,7 @@ function TopBar() {
const { data: channelsData } = useQuery<{ const { data: channelsData } = useQuery<{
rooms: ChannelItem[]; rooms: ChannelItem[];
categories: CategoryItem[]; categories: CategoryItem[];
workspace_id?: string;
}>({ }>({
queryKey: ["channel", "rooms"], queryKey: ["channel", "rooms"],
queryFn: async () => { queryFn: async () => {
@ -399,6 +403,8 @@ function TopBar() {
const channelName = roomId const channelName = roomId
? channelsData?.rooms?.find((r) => r.id === roomId)?.name ? channelsData?.rooms?.find((r) => r.id === roomId)?.name
: null; : null;
const workspaceId =
channelsData?.rooms?.[0]?.workspace_id ?? channelsData?.workspace_id ?? "";
const title = isRepo const title = isRepo
? segments[2] ? segments[2]
@ -458,6 +464,18 @@ function TopBar() {
> >
<Search className="size-[14px]" /> <Search className="size-[14px]" />
</button> </button>
{workspaceId && (
<InviteDialog roomId={roomId ?? undefined} workspaceId={workspaceId}>
<button
aria-label="Invite people"
className="grid size-10 place-items-center rounded-xl transition-[background-color,color] duration-150 hover:bg-accent/50 hover:text-muted-foreground/70"
title="Invite people"
type="button"
>
<UserPlus className="size-[14px]" />
</button>
</InviteDialog>
)}
<button <button
aria-label="Open members panel" aria-label="Open members panel"
className={cn( className={cn(
@ -551,15 +569,73 @@ function MembersPanel() {
); );
} }
function WorkspaceAccessLoading() {
return (
<div className="grid h-svh place-items-center bg-background">
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<div className="size-5 animate-spin rounded-full border-2 border-primary/30 border-t-primary" />
Loading
</div>
</div>
);
}
function WorkspaceAccessMessage({
actionHref,
actionText,
description,
title,
}: {
actionHref: string;
actionText: string;
description: string;
title: string;
}) {
return (
<div className="grid h-svh place-items-center bg-background px-4">
<section className="w-full max-w-md rounded-2xl border border-border bg-card p-6 text-center shadow-sm">
<h1 className="text-base font-semibold text-foreground">{title}</h1>
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
<Button asChild className="mt-5">
<Link to={actionHref}>{actionText}</Link>
</Button>
</section>
</div>
);
}
function apiErrorStatus(error: unknown) {
return (error as { response?: { status?: number } } | null)?.response?.status;
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Shell */ /* Shell */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
export function WorkspaceShell() { export function WorkspaceShell() {
const { projectName = "" } = useParams();
const location = useLocation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const isEmbed = searchParams.get("embed") === "1"; const isEmbed = searchParams.get("embed") === "1";
const [showMembers, setShowMembers] = useState(false); const [showMembers, setShowMembers] = useState(false);
const [showSearch, setShowSearch] = useState(false); const [showSearch, setShowSearch] = useState(false);
const { isAuthenticated, isLoading: authLoading } = useAuth();
const {
data: workspace,
error: workspaceError,
isLoading: workspaceLoading,
} = useQuery({
queryKey: ["workspace", projectName],
queryFn: async () => {
const response = await client.workspaceGetWorkspace(projectName);
return response.data;
},
enabled: isAuthenticated && Boolean(projectName),
retry: false,
});
const hasWorkspaceAccess = Boolean(workspace);
const { data: channelsData } = useQuery<{ const { data: channelsData } = useQuery<{
rooms: ChannelItem[]; rooms: ChannelItem[];
@ -571,6 +647,7 @@ export function WorkspaceShell() {
const response = await api.get("/api/v1/ws/rooms"); const response = await api.get("/api/v1/ws/rooms");
return response.data; return response.data;
}, },
enabled: hasWorkspaceAccess,
retry: false, retry: false,
}); });
const workspaceId = const workspaceId =
@ -588,7 +665,7 @@ export function WorkspaceShell() {
); );
return res.data; return res.data;
}, },
enabled: Boolean(workspaceId), enabled: hasWorkspaceAccess && Boolean(workspaceId),
retry: false, retry: false,
staleTime: 30000, staleTime: 30000,
}); });
@ -607,6 +684,49 @@ export function WorkspaceShell() {
[showMembers, showSearch, members, membersLoading], [showMembers, showSearch, members, membersLoading],
); );
if (authLoading) {
return <WorkspaceAccessLoading />;
}
if (!isAuthenticated) {
return (
<WorkspaceAccessMessage
actionHref={`/auth/login?redirect=${encodeURIComponent(location.pathname + location.search)}`}
actionText="去登录"
description="登录后才能查看 workspace、频道、成员等受限数据。"
title="请先登录"
/>
);
}
if (workspaceLoading) {
return <WorkspaceAccessLoading />;
}
if (apiErrorStatus(workspaceError) === 403) {
return (
<NavShell>
<WorkspaceAccessMessage
actionHref={`/${projectName}/join`}
actionText="申请加入"
description="你还不是该 workspace 的成员,申请通过后才能查看其中的数据。"
title="需要加入 workspace"
/>
</NavShell>
);
}
if (workspaceError) {
return (
<WorkspaceAccessMessage
actionHref="/"
actionText="返回首页"
description="无法打开该 workspace请确认链接是否正确或稍后再试。"
title="无法访问 workspace"
/>
);
}
// Embed mode: render only the page content, no shell chrome // Embed mode: render only the page content, no shell chrome
if (isEmbed) { if (isEmbed) {
return ( return (

View File

@ -1,12 +1,15 @@
import { import {
createContext, createContext,
useContext, useContext,
useEffect,
useRef,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import axios from "axios"; import axios from "axios";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { client, type ContextMe } from "@/client"; import { client, type ContextMe } from "@/client";
import { setFaroUser, pushFaroEvent } from "@/faro";
type AuthContextValue = { type AuthContextValue = {
me: ContextMe | null; me: ContextMe | null;
@ -36,10 +39,45 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const { data, error, isLoading, refetch } = useQuery({ const { data, error, isLoading, refetch } = useQuery({
queryKey: ["auth", "me"], queryKey: ["auth", "me"],
queryFn: fetchMe, queryFn: fetchMe,
staleTime: 5 * 60 * 1000, // 5 min — avoid re-fetch on every mount
retry: false, retry: false,
}); });
const me = data ?? null; const me = data ?? null;
const prevMe = useRef<ContextMe | null>(null);
useEffect(() => {
if (me) {
setFaroUser({
id: me.id,
username: me.username,
attributes: {
displayName: me.display_name ?? '',
language: me.language,
timezone: me.timezone,
},
});
// Login event: user was null → now authenticated
if (!prevMe.current) {
pushFaroEvent('auth_login', {
userId: me.id,
username: me.username,
});
}
} else if (!isLoading) {
// Logout event: user was authenticated → now null
if (prevMe.current) {
pushFaroEvent('auth_logout', {
userId: prevMe.current.id,
username: prevMe.current.username,
});
}
setFaroUser(null);
}
prevMe.current = me;
}, [me, isLoading]);
return ( return (
<AuthContext.Provider <AuthContext.Provider

144
src/faro.tsx Normal file
View File

@ -0,0 +1,144 @@
import {
initializeFaro,
getWebInstrumentations,
faro,
type MetaUser,
} from '@grafana/faro-web-sdk';
import { LogLevel } from '@grafana/faro-web-sdk';
import { OtlpHttpTransport } from '@grafana/faro-transport-otlp-http';
/**
* Patterns for errors that should be ignored (browser extensions, network flakes, etc).
* These errors are external to the app and not actionable.
*/
const IGNORE_ERROR_PATTERNS = [
/ResizeObserver loop limit exceeded/i,
/ResizeObserver loop completed with undelivered notifications/i,
/Script error\.?$/i,
/@parcel|moz-extension|chrome-extension|safari-extension/i,
/Non-Error promise rejection captured/i,
/Request aborted/i,
/AbortError/i,
/NetworkError/i,
/Load failed/i,
/^Loading chunk \d+ failed/i,
/^Importing a module script failed/i,
/^Cancel rendering route/i,
];
/**
* Initialize Grafana Faro Real User Monitoring (RUM) for the frontend.
*
* Collects:
* - Uncaught exceptions & unhandled promise rejections
* - console.error / console.warn
* - Web Vitals (LCP, FCP, CLS, INP, TTFB)
* - Session metadata (browser, OS, screen size etc.)
* - Page navigation / route changes (via History API)
* - User interactions (click, keypress etc.)
* - Custom events pushed via faro.api.pushEvent / pushMeasurement
*/
initializeFaro({
url: 'https://faro.gitdata.me/collect',
app: {
name: 'gitdataai',
version: __APP_VERSION__,
environment: __APP_ENV__,
namespace: 'frontend',
},
sessionTracking: {
enabled: true,
// Sample 100% of sessions for telemetry; adjust in production if needed
samplingRate: 1,
// Persist session across page reloads via sessionStorage
persistent: true,
},
instrumentations: [
...getWebInstrumentations({
// Capture console.error and console.warn as Faro logs
captureConsole: true,
// Enable Web Vitals with attribution for richer debugging
enablePerformanceInstrumentation: true,
}),
],
// Only capture console.error / console.warn (not debug/info/log) to keep noise low
consoleInstrumentation: {
disabledLevels: [LogLevel.DEBUG, LogLevel.INFO, LogLevel.LOG, LogLevel.TRACE],
},
// Filter errors before they are sent
ignoreErrors: IGNORE_ERROR_PATTERNS,
// Deduplicate identical errors within a session
dedupe: true,
// Use OTLP HTTP transport (already in package.json deps)
transports: [
new OtlpHttpTransport({
bufferSize: 30,
concurrency: 5,
defaultRateLimitBackoffMs: 5000,
}),
],
// Transform / filter every event before sending to the collector
beforeSend(item) {
// Strip query strings & hashes from page URL to avoid leaking tokens/secrets
if (item.meta?.page?.url) {
try {
const url = new URL(item.meta.page.url, window.location.origin);
url.search = '';
url.hash = '';
item.meta.page.url = url.toString();
} catch {
// Not a valid URL — leave as-is
}
}
return item;
},
});
/**
* Set Faro user context call after auth state changes.
*/
export function setFaroUser(user: MetaUser | null) {
if (user) {
faro.api.setUser(user);
} else {
faro.api.resetUser();
}
}
/**
* Clear Faro user context call on logout.
*/
export function clearFaroUser() {
faro.api.resetUser();
}
/**
* Push a custom event to Faro.
*/
export function pushFaroEvent(
name: string,
attributes?: Record<string, string>,
) {
faro.api.pushEvent(name, attributes);
}
/**
* Push a custom measurement to Faro.
*/
export function pushFaroMeasurement(
type: string,
values: Record<string, number>,
) {
faro.api.pushMeasurement({ type, values });
}
export { faro };

View File

@ -0,0 +1,26 @@
import { useRef, useEffect, useCallback } from 'react';
import { pushFaroMeasurement } from '@/faro';
/**
* Hook for measuring custom durations and reporting to Faro.
*
* @example
* const measure = useFaroMeasure('api-call');
* await fetch('/api/...');
* measure({ duration: Date.now() - start });
*/
export function useFaroMeasure(type: string) {
const startRef = useRef<number>(0);
useEffect(() => {
startRef.current = performance.now();
}, []);
return useCallback(
(values: Record<string, number>) => {
const elapsed = performance.now() - startRef.current;
pushFaroMeasurement(type, { ...values, elapsedMs: Math.round(elapsed) });
},
[type],
);
}

View File

@ -1,5 +1,5 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { AtSign, Hash, User, GitBranch, Bug } from 'lucide-react'; import { AtSign, Hash, User, GitBranch, Bug, Megaphone } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { MentionData } from './parser'; import type { MentionData } from './parser';
@ -9,6 +9,7 @@ const entityIcons: Record<string, React.ComponentType<{ className?: string }>> =
issue: Bug, issue: Bug,
pr: GitBranch, pr: GitBranch,
room: Hash, room: Hash,
all: Megaphone,
}; };
const entityColors: Record<string, string> = { const entityColors: Record<string, string> = {
@ -17,6 +18,7 @@ const entityColors: Record<string, string> = {
issue: 'text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-950', issue: 'text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-950',
pr: 'text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-950', pr: 'text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-950',
room: 'text-info dark:text-info bg-info/10 dark:bg-info/20', room: 'text-info dark:text-info bg-info/10 dark:bg-info/20',
all: 'text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950',
}; };
interface Props { interface Props {

105
src/lib/sanitize-html.ts Normal file
View File

@ -0,0 +1,105 @@
const DANGEROUS_TAGS = new Set([
"script",
"style",
"iframe",
"object",
"embed",
"svg",
"math",
"link",
"meta",
]);
const ALLOWED_TAGS = new Set([
"a",
"p",
"br",
"strong",
"b",
"em",
"i",
"u",
"s",
"code",
"pre",
"blockquote",
"ul",
"ol",
"li",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
"img",
]);
const ALLOWED_ATTRS = new Set(["href", "src", "alt", "title"]);
function isSafeUrl(value: string) {
const trimmed = value.trim();
if (trimmed.startsWith("#") || trimmed.startsWith("/")) return true;
try {
const url = new URL(trimmed, window.location.origin);
return ["http:", "https:", "mailto:", "tel:"].includes(url.protocol);
} catch {
return false;
}
}
function sanitizeAttributes(element: Element) {
for (const attr of Array.from(element.attributes)) {
const name = attr.name.toLowerCase();
const value = attr.value;
const isUrlAttr = name === "href" || name === "src";
if (!ALLOWED_ATTRS.has(name) || name.startsWith("on") || (isUrlAttr && !isSafeUrl(value))) {
element.removeAttribute(attr.name);
}
}
if (element.tagName.toLowerCase() === "a") {
element.setAttribute("rel", "noopener noreferrer");
element.setAttribute("target", "_blank");
}
}
function sanitizeNode(node: Node) {
if (node.nodeType !== Node.ELEMENT_NODE) return;
const element = node as Element;
const tag = element.tagName.toLowerCase();
if (DANGEROUS_TAGS.has(tag)) {
element.remove();
return;
}
if (!ALLOWED_TAGS.has(tag)) {
const children = Array.from(element.childNodes);
for (const child of children) sanitizeNode(child);
element.replaceWith(...children);
return;
}
sanitizeAttributes(element);
for (const child of Array.from(element.childNodes)) sanitizeNode(child);
}
export function sanitizeHtml(html: string) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
for (const child of Array.from(doc.body.childNodes)) sanitizeNode(child);
return doc.body.innerHTML;
}

View File

@ -1,6 +1,7 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import './faro'
import App from './App.tsx' import App from './App.tsx'
import {QueryClient, QueryClientProvider} from "@tanstack/react-query"; import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
import { AuthProvider } from "@/context/auth-context"; import { AuthProvider } from "@/context/auth-context";

View File

@ -1,5 +1,5 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { Link, useNavigate } from "react-router"; import { Link, useNavigate, useSearchParams } from "react-router";
import axios from "axios"; import axios from "axios";
import { client } from "@/client"; import { client } from "@/client";
@ -31,6 +31,11 @@ type LoginPayload = {
captcha: string; captcha: string;
}; };
function safeRedirect(value: string | null) {
if (!value || !value.startsWith("/") || value.startsWith("//")) return "/";
return value;
}
function isTwoFactorRequired(error: unknown) { function isTwoFactorRequired(error: unknown) {
return ( return (
axios.isAxiosError(error) && axios.isAxiosError(error) &&
@ -41,6 +46,7 @@ function isTwoFactorRequired(error: unknown) {
export default function LoginPage() { export default function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { refresh } = useAuth(); const { refresh } = useAuth();
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [twoFactorSubmitting, setTwoFactorSubmitting] = useState(false); const [twoFactorSubmitting, setTwoFactorSubmitting] = useState(false);
@ -56,7 +62,7 @@ export default function LoginPage() {
totp_code: totpCode || undefined, totp_code: totpCode || undefined,
}); });
await refresh(); await refresh();
navigate("/"); navigate(safeRedirect(searchParams.get("redirect")));
}; };
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => { const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {

136
src/page/join-invite.tsx Normal file
View File

@ -0,0 +1,136 @@
import { useCallback, useMemo, useState } from "react";
import { Link, Navigate, useLocation, useNavigate, useParams } from "react-router";
import { CheckCircle2, Loader2, UserPlus, XCircle } from "lucide-react";
import { api } from "@/client";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/context/auth-context";
type InviteAcceptEvent = {
data?: {
workspace?: { name?: string };
room?: { id?: string } | null;
};
};
function CenteredMessage({
actionHref,
actionText,
description,
title,
}: {
actionHref?: string;
actionText?: string;
description: string;
title: string;
}) {
return (
<main className="grid min-h-screen place-items-center bg-background px-4">
<section className="w-full max-w-md rounded-2xl border border-border bg-card p-6 text-center shadow-sm">
<h1 className="text-base font-semibold text-foreground">{title}</h1>
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
{actionHref && actionText && (
<Button asChild className="mt-5">
<Link to={actionHref}>{actionText}</Link>
</Button>
)}
</section>
</main>
);
}
export default function JoinInvitePage() {
const { code = "" } = useParams();
const navigate = useNavigate();
const location = useLocation();
const { isAuthenticated, isLoading: authLoading } = useAuth();
const [accepting, setAccepting] = useState(false);
const [acceptedTo, setAcceptedTo] = useState<string | null>(null);
const [error, setError] = useState("");
const targetPath = useMemo(() => {
if (!acceptedTo) return null;
return acceptedTo;
}, [acceptedTo]);
const acceptInvite = useCallback(async () => {
if (!code || accepting) return;
setAccepting(true);
setError("");
try {
const res = await api.post<InviteAcceptEvent>("/api/v1/ws/invites/accept", { code });
const workspaceName = res.data?.data?.workspace?.name;
const roomId = res.data?.data?.room?.id;
const path = workspaceName
? roomId
? `/${workspaceName}/channel/${roomId}`
: `/${workspaceName}/channel`
: "/me";
setAcceptedTo(path);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to accept invite.");
} finally {
setAccepting(false);
}
}, [accepting, code]);
if (!code) return <Navigate replace to="/" />;
if (authLoading) {
return <CenteredMessage description="Checking login status…" title="Loading" />;
}
if (!isAuthenticated) {
return (
<CenteredMessage
actionHref={`/auth/login?redirect=${encodeURIComponent(location.pathname + location.search)}`}
actionText="去登录"
description="登录后才能接受邀请,未登录状态不会展示 workspace 邀请内容。"
title="请先登录"
/>
);
}
return (
<main className="grid min-h-screen place-items-center bg-background px-4">
<section className="w-full max-w-md rounded-2xl border border-border bg-card p-6 shadow-sm">
<div className="mb-5 flex items-center gap-3">
<div className="grid size-10 place-items-center rounded-xl bg-primary/10 text-primary">
<UserPlus className="size-5" />
</div>
<div>
<h1 className="text-base font-semibold text-foreground">Join workspace</h1>
<p className="text-sm text-muted-foreground">Accept this invitation to continue.</p>
</div>
</div>
{targetPath ? (
<div className="space-y-4">
<div className="flex items-start gap-3 rounded-xl bg-emerald-500/10 px-4 py-3 text-sm text-emerald-600">
<CheckCircle2 className="mt-0.5 size-4 shrink-0" />
<span>Invite accepted. You can now open the workspace.</span>
</div>
<Button className="w-full" onClick={() => navigate(targetPath)}>
Open workspace
</Button>
</div>
) : (
<div className="space-y-4">
{error && (
<div className="flex items-start gap-3 rounded-xl bg-destructive/10 px-4 py-3 text-sm text-destructive" role="alert">
<XCircle className="mt-0.5 size-4 shrink-0" />
<span>{error}</span>
</div>
)}
<Button className="w-full" disabled={accepting} onClick={acceptInvite}>
{accepting && <Loader2 className="mr-2 size-4 animate-spin" />}
Accept invite
</Button>
<Button asChild className="w-full" variant="outline">
<Link to="/">Back home</Link>
</Button>
</div>
)}
</section>
</main>
);
}

View File

@ -6,6 +6,12 @@ import TerminalDemo from "@/components/landing/terminal-demo";
import DashboardMockup from "@/components/landing/dashboard-mockup"; import DashboardMockup from "@/components/landing/dashboard-mockup";
import { GitBranch, Layers, MessageSquare, Bot } from "lucide-react"; import { GitBranch, Layers, MessageSquare, Bot } from "lucide-react";
const productHighlights = [
{ label: "Git-native", value: "Repos, PRs, branches" },
{ label: "AI in context", value: "Review, triage, automate" },
{ label: "Team sync", value: "Channels tied to work" },
];
export default function LandingHome() { export default function LandingHome() {
return ( return (
<> <>
@ -55,11 +61,7 @@ export default function LandingHome() {
{/* Product loop */} {/* Product loop */}
<section className="border-t border-border/20 bg-muted/[0.1] py-10"> <section className="border-t border-border/20 bg-muted/[0.1] py-10">
<div className="mx-auto grid max-w-6xl gap-3 px-6 sm:grid-cols-3"> <div className="mx-auto grid max-w-6xl gap-3 px-6 sm:grid-cols-3">
{[ {productHighlights.map((item) => (
{ label: "Git-native", value: "Repos, PRs, branches" },
{ label: "AI in context", value: "Review, triage, automate" },
{ label: "Team sync", value: "Channels tied to work" },
].map((item) => (
<div className="rounded-2xl border border-border/30 bg-card/70 px-5 py-4 text-center shadow-sm" key={item.label}> <div className="rounded-2xl border border-border/30 bg-card/70 px-5 py-4 text-center shadow-sm" key={item.label}>
<div className="font-heading text-sm font-semibold text-foreground">{item.label}</div> <div className="font-heading text-sm font-semibold text-foreground">{item.label}</div>
<div className="mt-1 text-xs text-muted-foreground/70">{item.value}</div> <div className="mt-1 text-xs text-muted-foreground/70">{item.value}</div>

View File

@ -132,8 +132,8 @@ export default function MeChatConversationPage() {
behavior: "instant", behavior: "instant",
}); });
}); });
} catch { } catch (err) {
// Non-critical. console.error("Failed to load conversation:", err);
} }
}, [conversationId]); }, [conversationId]);
@ -552,29 +552,32 @@ function ChatComposer({
onModelChange: (provider: string) => void; onModelChange: (provider: string) => void;
}) { }) {
const { textInput } = usePromptInputController(); const { textInput } = usePromptInputController();
const textInputRef = useRef(textInput);
textInputRef.current = textInput;
const handleSubmit = useCallback( const handleSubmit = useCallback(
(text: string) => { (text: string) => {
if (text.trim()) { if (text.trim()) {
sendMessage(text); sendMessage(text);
textInput.clear(); textInputRef.current.clear();
} }
}, },
[sendMessage, textInput], [sendMessage],
); );
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => { (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && !sending) { if (e.key === "Enter" && !e.shiftKey && !sending) {
e.preventDefault(); e.preventDefault();
const text = textInput.value; const ti = textInputRef.current;
const text = ti.value;
if (text.trim()) { if (text.trim()) {
sendMessage(text); sendMessage(text);
textInput.clear(); ti.clear();
} }
} }
}, },
[sendMessage, textInput, sending], [sendMessage, sending],
); );
return ( return (

View File

@ -83,7 +83,7 @@ export default function MeFollowingPage() {
<div className="flex items-center gap-3 rounded-lg px-3 py-3 hover:bg-accent/50 transition-colors" key={user.username}> <div className="flex items-center gap-3 rounded-lg px-3 py-3 hover:bg-accent/50 transition-colors" key={user.username}>
<Link className="flex items-center gap-3 min-w-0 flex-1" to={`/${user.username}`}> <Link className="flex items-center gap-3 min-w-0 flex-1" to={`/${user.username}`}>
<span className={`grid size-8 place-items-center rounded-lg bg-gradient-to-br text-xs font-bold text-white ${workspaceColor(user.username)}`}> <span className={`grid size-8 place-items-center rounded-lg bg-gradient-to-br text-xs font-bold text-white ${workspaceColor(user.username)}`}>
{user.avatar_url ? <img alt="" className="size-full object-cover" src={user.avatar_url} /> : workspaceInitial(user.username)} {user.avatar_url ? <img alt={user.username + " 的头像"} className="size-full object-cover" src={user.avatar_url} /> : workspaceInitial(user.username)}
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<p className="font-heading font-medium text-foreground">{user.display_name ?? user.username}</p> <p className="font-heading font-medium text-foreground">{user.display_name ?? user.username}</p>

View File

@ -28,7 +28,7 @@ export function WorkspaceList({ workspaces }: { workspaces: { name: string; avat
<span <span
className={`grid size-7 place-items-center rounded-lg bg-gradient-to-br text-xs font-bold text-white ${workspaceColor(ws.name)}`} className={`grid size-7 place-items-center rounded-lg bg-gradient-to-br text-xs font-bold text-white ${workspaceColor(ws.name)}`}
> >
{ws.avatar_url ? <img alt="" className="size-full object-cover" src={ws.avatar_url} /> : workspaceInitial(ws.name)} {ws.avatar_url ? <img alt={ws.name + " 的头像"} className="size-full object-cover" src={ws.avatar_url} /> : workspaceInitial(ws.name)}
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">

View File

@ -1,3 +1,4 @@
import { useMemo } from "react";
import { useAuth } from "@/context/auth-context"; import { useAuth } from "@/context/auth-context";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { client } from "@/client"; import { client } from "@/client";
@ -49,13 +50,20 @@ export default function MeOverviewPage() {
? new Date(createdAt).toLocaleDateString("en-US", { month: "short", year: "numeric" }) ? new Date(createdAt).toLocaleDateString("en-US", { month: "short", year: "numeric" })
: ""; : "";
const stats = useMemo(() => [
{ value: workspaces?.length ?? 0, label: "Workspaces", icon: Folder, to: "/me/repos" },
{ value: 0, label: "Repositories", icon: GitFork, to: "/me/repos" },
{ value: 0, label: "Stars", icon: Star, to: "/me/stars" },
{ value: relationCounts?.followers ?? 0, label: "Followers", icon: Users, to: "/me/following" },
], [workspaces?.length, relationCounts?.followers]);
return ( return (
<div className="mx-auto max-w-4xl px-8 py-10"> <div className="mx-auto max-w-4xl px-8 py-10">
{/* Profile header */} {/* Profile header */}
<div className="flex items-center gap-5"> <div className="flex items-center gap-5">
<div className="grid size-16 place-items-center overflow-hidden rounded-xl bg-gradient-to-br text-xl font-heading font-bold text-white ring-1 ring-black/5 shadow-sm"> <div className="grid size-16 place-items-center overflow-hidden rounded-xl bg-gradient-to-br text-xl font-heading font-bold text-white ring-1 ring-black/5 shadow-sm">
{avatarUrl ? ( {avatarUrl ? (
<img alt="" className="size-full object-cover" src={avatarUrl} /> <img alt={displayName + " 的头像"} className="size-full object-cover" src={avatarUrl} />
) : ( ) : (
workspaceInitial(displayName) workspaceInitial(displayName)
)} )}
@ -86,12 +94,7 @@ export default function MeOverviewPage() {
{/* Stats */} {/* Stats */}
<div className="mt-8 grid grid-cols-4 gap-3"> <div className="mt-8 grid grid-cols-4 gap-3">
{[ {stats.map((stat) => (
{ value: workspaces?.length ?? 0, label: "Workspaces", icon: Folder, to: "/me/repos" },
{ value: 0, label: "Repositories", icon: GitFork, to: "/me/repos" },
{ value: 0, label: "Stars", icon: Star, to: "/me/stars" },
{ value: relationCounts?.followers ?? 0, label: "Followers", icon: Users, to: "/me/following" },
].map((stat) => (
<Link <Link
className="group rounded-lg border border-border bg-card px-4 py-3 transition-colors hover:border-primary/20" className="group rounded-lg border border-border bg-card px-4 py-3 transition-colors hover:border-primary/20"
key={stat.label} key={stat.label}

View File

@ -50,7 +50,7 @@ export default function MeReposPage() {
<span <span
className={`grid size-8 place-items-center rounded-lg bg-gradient-to-br text-xs font-bold text-white ${workspaceColor(ws.name)}`} className={`grid size-8 place-items-center rounded-lg bg-gradient-to-br text-xs font-bold text-white ${workspaceColor(ws.name)}`}
> >
{ws.avatar_url ? <img alt="" className="size-full object-cover" src={ws.avatar_url} /> : workspaceInitial(ws.name)} {ws.avatar_url ? <img alt={ws.name + " 的头像"} className="size-full object-cover" src={ws.avatar_url} /> : workspaceInitial(ws.name)}
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@ -1,4 +1,5 @@
import { useQuery, useMutation } from "@tanstack/react-query"; import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { client } from "@/client"; import { client } from "@/client";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -7,6 +8,10 @@ import { Label } from "@/components/ui/label";
import { Shield, ShieldOff, Mail } from "lucide-react"; import { Shield, ShieldOff, Mail } from "lucide-react";
function TwoFactorSection() { function TwoFactorSection() {
const queryClient = useQueryClient();
const [verificationCode, setVerificationCode] = useState("");
const [verifiedBackupCodes, setVerifiedBackupCodes] = useState<string[]>([]);
const { data: status, isLoading } = useQuery({ const { data: status, isLoading } = useQuery({
queryKey: ["auth", "2fa", "status"], queryKey: ["auth", "2fa", "status"],
queryFn: async () => { queryFn: async () => {
@ -23,6 +28,17 @@ function TwoFactorSection() {
}, },
}); });
const verify2fa = useMutation({
mutationFn: async (code: string) => {
await client.authVerify2fa({ code });
},
onSuccess: () => {
setVerifiedBackupCodes(enable2fa.data?.backup_codes ?? []);
setVerificationCode("");
queryClient.invalidateQueries({ queryKey: ["auth", "2fa", "status"] });
},
});
if (isLoading) { if (isLoading) {
return <div className="h-16 animate-pulse rounded-lg bg-muted" />; return <div className="h-16 animate-pulse rounded-lg bg-muted" />;
} }
@ -37,11 +53,23 @@ function TwoFactorSection() {
</CardTitle> </CardTitle>
<CardDescription>Your account is protected with 2FA</CardDescription> <CardDescription>Your account is protected with 2FA</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-3">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Method: {status.method ?? "TOTP"} Method: {status.method ?? "TOTP"}
{status.has_backup_codes ? " — Backup codes available" : " — No backup codes"} {status.has_backup_codes ? " — Backup codes available" : " — No backup codes"}
</p> </p>
{verifiedBackupCodes.length > 0 && (
<div className="space-y-2 rounded-lg border border-border p-3">
<p className="font-heading text-sm font-medium text-foreground">
Backup codes save these now. They won't be shown again.
</p>
<div className="grid grid-cols-2 gap-1">
{verifiedBackupCodes.map((code) => (
<p className="font-mono text-xs text-muted-foreground" key={code}>{code}</p>
))}
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
); );
@ -58,21 +86,37 @@ function TwoFactorSection() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{enable2fa.data ? ( {enable2fa.data ? (
<div className="space-y-4"> <form
<p className="text-sm text-foreground font-medium"> className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
verify2fa.mutate(verificationCode.trim());
}}
>
<p className="text-sm font-medium text-foreground">
Scan this QR code with your authenticator app, then enter the code below to verify. Scan this QR code with your authenticator app, then enter the code below to verify.
</p> </p>
<img alt="2FA QR code" className="size-48 rounded-lg border border-border" src={enable2fa.data.qr_code} /> <img alt="2FA QR code" className="size-48 rounded-lg border border-border" src={enable2fa.data.qr_code} />
<p className="font-mono text-xs text-muted-foreground">Secret: {enable2fa.data.secret}</p> <p className="font-mono text-xs text-muted-foreground">Secret: {enable2fa.data.secret}</p>
<div className="space-y-2"> <div className="space-y-2">
<p className="font-heading font-medium text-sm text-foreground">Backup codes (save these!)</p> <Label htmlFor="two-factor-code">Verification code</Label>
<div className="grid grid-cols-2 gap-1"> <Input
{enable2fa.data.backup_codes.map((code) => ( className="h-9 max-w-48 font-mono"
<p className="font-mono text-xs text-muted-foreground" key={code}>{code}</p> id="two-factor-code"
))} inputMode="numeric"
</div> maxLength={8}
</div> onChange={(e) => setVerificationCode(e.target.value)}
placeholder="123456"
value={verificationCode}
/>
</div> </div>
{verify2fa.isError && (
<p className="text-sm text-destructive">Invalid code. Please try again.</p>
)}
<Button disabled={!verificationCode.trim() || verify2fa.isPending} size="sm" type="submit">
Verify and enable
</Button>
</form>
) : ( ) : (
<Button onClick={() => enable2fa.mutate()}> <Button onClick={() => enable2fa.mutate()}>
Enable 2FA Enable 2FA

View File

@ -1,43 +1,71 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Reply, Send, X } from "lucide-react"; import { useParams } from "react-router";
import { Reply, Send, SmilePlus, X } from "lucide-react";
import EmojiPicker, { type EmojiClickData } from "emoji-picker-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import FileUploadButton from "./file-upload"; import FileUploadButton, { type UploadedAttachment } from "./file-upload";
import MentionTextarea, { type MentionRoomSuggestion } from "./mention-textarea";
import type { MessageNewService } from "@/socket"; import type { MessageNewService } from "@/socket";
type RoomSuggestion = MentionRoomSuggestion;
// ---- Types ----
type Props = { type Props = {
roomName: string; roomName: string;
roomId?: string;
rooms?: RoomSuggestion[];
workspaceId?: string;
replyTarget?: MessageNewService | null; replyTarget?: MessageNewService | null;
onSend: (content: string) => Promise<void>; onSend: (content: string, attachmentIds?: string[]) => Promise<void>;
onCancelReply?: () => void; onCancelReply?: () => void;
onTyping?: (typing: boolean) => void; onTyping?: (typing: boolean) => void;
disabled?: boolean; disabled?: boolean;
}; };
// ---- Component ----
export default function MessageComposer({ export default function MessageComposer({
roomName, roomName,
roomId,
rooms = [],
workspaceId,
replyTarget, replyTarget,
onSend, onSend,
onCancelReply, onCancelReply,
onTyping, onTyping,
disabled, disabled,
}: Props) { }: Props) {
const { projectName = "" } = useParams();
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
const [emojiOpen, setEmojiOpen] = useState(false);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const typingTimeout = useRef<number | undefined>(undefined); const typingTimeout = useRef<number | undefined>(undefined);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
// Focus textarea when entering reply mode // Focus textarea when entering reply mode (via imperative ref)
const containerRef = useRef<HTMLDivElement>(null);
const getTextarea = useCallback(() => containerRef.current?.querySelector("textarea") ?? null, []);
useEffect(() => { useEffect(() => {
if (replyTarget) { if (replyTarget) {
textareaRef.current?.focus(); // Small delay to let textarea mount
const timer = setTimeout(() => {
const ta = getTextarea();
ta?.focus();
}, 50);
return () => clearTimeout(timer);
} }
}, [replyTarget?.id]); }, [replyTarget?.id]);
const handleInput = useCallback( const handleChange = useCallback(
(value: string) => { (nextValue: string) => {
setContent(value); setContent(nextValue);
if (onTyping) { if (onTyping) {
onTyping(true); onTyping(true);
clearTimeout(typingTimeout.current); clearTimeout(typingTimeout.current);
@ -47,30 +75,52 @@ export default function MessageComposer({
[onTyping], [onTyping],
); );
const handleUploaded = useCallback((att: UploadedAttachment) => {
setAttachmentIds((prev) => [...prev, att.id]);
}, []);
const handleEmojiClick = useCallback(
(emoji: EmojiClickData) => {
const ta = getTextarea();
if (!ta) return;
const start = ta.selectionStart ?? ta.value.length;
const end = ta.selectionEnd ?? ta.value.length;
const next = ta.value.slice(0, start) + emoji.emoji + ta.value.slice(end);
// Set native value and dispatch input event so MentionTextarea picks it up
const nativeSetter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
nativeSetter?.call(ta, next);
ta.dispatchEvent(new Event("input", { bubbles: true }));
// Place cursor after inserted emoji
const caret = start + emoji.emoji.length;
ta.setSelectionRange(caret, caret);
ta.focus();
setEmojiOpen(false);
},
[getTextarea],
);
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
const text = content.trim(); const text = content.trim();
if (!text || sending) return; if (!text || sending) return;
setSending(true); setSending(true);
try { try {
await onSend(text); await onSend(text, attachmentIds.length > 0 ? attachmentIds : undefined);
setContent(""); setContent("");
setAttachmentIds([]);
onTyping?.(false); onTyping?.(false);
clearTimeout(typingTimeout.current); clearTimeout(typingTimeout.current);
} finally { } finally {
setSending(false); setSending(false);
} }
}, [content, sending, onSend, onTyping]); }, [content, sending, attachmentIds, onSend, onTyping]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit],
);
const hasContent = content.trim().length > 0; const hasContent = content.trim().length > 0;
@ -78,11 +128,10 @@ export default function MessageComposer({
replyTarget?.sender.display_name ?? replyTarget?.sender.display_name ??
replyTarget?.sender.username ?? replyTarget?.sender.username ??
"Unknown"; "Unknown";
const replyPreview = const replyPreview = replyTarget?.content.slice(0, 100) ?? "";
replyTarget?.content.slice(0, 100) ?? "";
return ( return (
<div className="shrink-0 px-4 pb-4 pt-1"> <div className="shrink-0 px-4 pb-4 pt-1" ref={containerRef}>
{/* Reply indicator */} {/* Reply indicator */}
{replyTarget && ( {replyTarget && (
<div className="mb-2 flex items-center gap-2 rounded-lg border border-primary/[0.08] bg-primary/[0.03] px-3 py-2"> <div className="mb-2 flex items-center gap-2 rounded-lg border border-primary/[0.08] bg-primary/[0.03] px-3 py-2">
@ -107,6 +156,7 @@ export default function MessageComposer({
</div> </div>
)} )}
{/* Composer with custom mention textarea */}
<div <div
className={cn( className={cn(
"relative rounded-2xl border bg-card/80 shadow-sm transition-[background-color,border-color,box-shadow] duration-200", "relative rounded-2xl border bg-card/80 shadow-sm transition-[background-color,border-color,box-shadow] duration-200",
@ -114,25 +164,55 @@ export default function MessageComposer({
replyTarget ? "border-primary/[0.12]" : "border-border/40", replyTarget ? "border-primary/[0.12]" : "border-border/40",
)} )}
> >
<Textarea <MentionTextarea
aria-label={`Message #${roomName}`}
className={cn(
"min-h-[52px] max-h-48 resize-none border-0 bg-transparent px-5 py-3.5 pr-28 text-[13px] leading-relaxed shadow-none focus-visible:ring-0",
"placeholder:text-muted-foreground/35",
)}
disabled={disabled || sending} disabled={disabled || sending}
onChange={(e) => handleInput(e.target.value)} onChange={handleChange}
onKeyDown={handleKeyDown} onSubmit={handleSubmit}
placeholder={ placeholder={
replyTarget replyTarget
? `Reply to ${replyAuthorName}` ? `Reply to ${replyAuthorName}`
: `Send a message in #${roomName}` : `Send a message in #${roomName} (use @ to mention)`
} }
ref={textareaRef} projectName={projectName}
rooms={rooms}
value={content} value={content}
workspaceId={workspaceId}
/> />
<div className="absolute right-2 bottom-2 flex items-center gap-1">
<FileUploadButton disabled={disabled || sending} /> {/* Action buttons (absolute positioned over the textarea) */}
<div className="absolute right-2 bottom-2 z-20 flex items-center gap-1">
<Popover onOpenChange={setEmojiOpen} open={emojiOpen}>
<PopoverTrigger
render={
<Button
aria-label="Open emoji picker"
className="size-8 cursor-pointer rounded-lg text-muted-foreground/40 hover:bg-accent/50 hover:text-muted-foreground"
disabled={disabled || sending}
size="icon"
title="Add emoji"
type="button"
variant="ghost"
>
<SmilePlus className="size-[15px]" />
</Button>
}
/>
<PopoverContent
align="end"
className="w-auto border-0 bg-transparent p-0 shadow-none"
side="top"
sideOffset={8}
>
<div className="overflow-hidden rounded-2xl border border-border/60 bg-card shadow-xl">
<EmojiPicker
height={360}
onEmojiClick={handleEmojiClick}
width={320}
/>
</div>
</PopoverContent>
</Popover>
<FileUploadButton disabled={disabled || sending} onUploaded={handleUploaded} roomId={roomId} />
<Button <Button
aria-label="Send message" aria-label="Send message"
className={cn( className={cn(

View File

@ -1,7 +1,16 @@
import { useCallback, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Image, Loader2, Paperclip, X } from "lucide-react"; import { Image, Loader2, Paperclip, X } from "lucide-react";
import { api } from "@/client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
export type UploadedAttachment = {
id: string;
filename: string;
url: string | null;
size: number;
content_type: string | null;
};
type PendingFile = { type PendingFile = {
id: string; id: string;
name: string; name: string;
@ -13,13 +22,15 @@ type PendingFile = {
type Props = { type Props = {
disabled?: boolean; disabled?: boolean;
onUploaded?: (url: string, filename: string) => void; roomId?: string;
onUploaded?: (att: UploadedAttachment) => void;
maxFiles?: number; maxFiles?: number;
maxSize?: number; maxSize?: number;
}; };
export default function FileUploadButton({ export default function FileUploadButton({
disabled, disabled,
roomId,
onUploaded, onUploaded,
maxFiles = 5, maxFiles = 5,
maxSize = 50 * 1024 * 1024, maxSize = 50 * 1024 * 1024,
@ -28,6 +39,19 @@ export default function FileUploadButton({
const [error, setError] = useState(""); const [error, setError] = useState("");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const idCounter = useRef(0); const idCounter = useRef(0);
const filesRef = useRef<PendingFile[]>([]);
useEffect(() => {
filesRef.current = files;
}, [files]);
useEffect(() => {
return () => {
for (const file of filesRef.current) {
if (file.preview) URL.revokeObjectURL(file.preview);
}
};
}, []);
const handleFiles = useCallback( const handleFiles = useCallback(
(selected: FileList | null) => { (selected: FileList | null) => {
@ -59,24 +83,34 @@ export default function FileUploadButton({
: undefined, : undefined,
}; };
newFiles.push(pending); newFiles.push(pending);
simulateUpload(file)
.then(() => { // Real upload
setFiles((prev) => prev.filter((f) => f.id !== id)); uploadFile(file, id, roomId)
onUploaded?.(`blob:${file.name}`, file.name); .then((attachment) => {
setFiles((prev) => {
const uploaded = prev.find((f) => f.id === id);
if (uploaded?.preview) URL.revokeObjectURL(uploaded.preview);
return prev.filter((f) => f.id !== id);
});
onUploaded?.(attachment);
}) })
.catch(() => { .catch((err) => {
setFiles((prev) => setFiles((prev) =>
prev.map((f) => prev.map((f) =>
f.id === id ? { ...f, uploading: false, progress: 0 } : f, f.id === id ? { ...f, uploading: false, progress: 0 } : f,
), ),
); );
setError(`Failed to upload ${file.name}`); setError(
err instanceof Error
? `Failed to upload ${file.name}: ${err.message}`
: `Failed to upload ${file.name}`,
);
}); });
} }
setFiles((prev) => [...prev, ...newFiles]); setFiles((prev) => [...prev, ...newFiles]);
if (inputRef.current) inputRef.current.value = ""; if (inputRef.current) inputRef.current.value = "";
}, },
[files.length, maxFiles, maxSize, onUploaded], [files.length, maxFiles, maxSize, onUploaded, roomId],
); );
const removeFile = useCallback((id: string) => { const removeFile = useCallback((id: string) => {
@ -178,8 +212,23 @@ export default function FileUploadButton({
); );
} }
async function simulateUpload(_file: File): Promise<void> { async function uploadFile(
return new Promise((resolve) => { file: File,
setTimeout(resolve, 800 + Math.random() * 1200); _pendingId: string,
}); roomId?: string,
): Promise<UploadedAttachment> {
if (!roomId) throw new Error("No room selected");
const res = await api.post(
`/api/v1/ws/rooms/${roomId}/attachments`,
file,
{
headers: {
"Content-Type": file.type || "application/octet-stream",
},
params: { filename: file.name },
},
);
return res.data as UploadedAttachment;
} }

View File

@ -0,0 +1,223 @@
import { useEffect, useState } from "react";
import {
ExternalLink,
GitFork,
Loader2,
Star,
Circle,
Clock,
} from "lucide-react";
import type { GithubLinkMatch } from "./github-link-parser";
type GithubRepo = {
full_name: string;
description: string | null;
html_url: string;
language: string | null;
stargazers_count: number;
forks_count: number;
updated_at: string;
owner: {
avatar_url: string;
login: string;
};
topics: string[];
license: {
spdx_id: string;
} | null;
};
function languageColor(lang: string): string {
const map: Record<string, string> = {
Rust: "#DEA584",
TypeScript: "#3178C6",
JavaScript: "#F7DF1E",
Python: "#3572A5",
Go: "#00ADD8",
Java: "#B07219",
Kotlin: "#A97BFF",
Swift: "#F05138",
C: "#555555",
"C++": "#F34B7D",
"C#": "#178600",
Ruby: "#701516",
Zig: "#EC915C",
Elixir: "#6E4A7E",
Haskell: "#5E5086",
CSS: "#563D7C",
HTML: "#E34C26",
Shell: "#89E051",
PHP: "#4F5D95",
Dart: "#00B4AB",
Scala: "#C22D40",
R: "#198CE7",
Lua: "#000080",
Vue: "#41B883",
Svelte: "#FF3E00",
MDX: "#FCB32C",
Dockerfile: "#384D54",
Makefile: "#427819",
Markdown: "#083FA1",
Nix: "#7E7EFF",
OCaml: "#3BE133",
Objective_C: "#438EFF",
Perl: "#0298C3",
Erlang: "#B83998",
CMake: "#DA3434",
PowerShell: "#012456",
SQL: "#E38C00",
Solidity: "#AA6746",
Terraform: "#7B42BC",
Vim_Script: "#199F4B",
};
return map[lang] ?? "#6B7280";
}
function formatCount(n: number): string {
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return n.toString();
}
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const days = Math.floor(diff / 86400000);
if (days < 1) return "today";
if (days === 1) return "yesterday";
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.floor(months / 12)}y ago`;
}
export default function GithubEmbedCard({ link }: { link: GithubLinkMatch }) {
const [repo, setRepo] = useState<GithubRepo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(false);
fetch(`https://api.github.com/repos/${link.owner}/${link.repo}`)
.then(async (res) => {
if (!res.ok) throw new Error("not found");
return res.json();
})
.then((data: GithubRepo) => {
if (!cancelled) setRepo(data);
})
.catch(() => {
if (!cancelled) setError(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [link.owner, link.repo]);
return (
<a
className="mt-2 block max-w-[420px] rounded-xl border border-border/30 bg-muted/[0.03] p-4 transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-200 hover:border-primary/20 hover:bg-muted/[0.08] hover:shadow-sm"
href={link.url}
rel="noopener noreferrer"
target="_blank"
>
{loading ? (
<div className="flex items-center gap-2 py-2 text-[13px] text-muted-foreground/50">
<Loader2 className="size-4 animate-spin" />
Loading GitHub repo
</div>
) : error || !repo ? (
<div className="flex items-center gap-2">
<div className="grid size-8 shrink-0 place-items-center rounded-lg bg-muted/40">
<svg
className="size-4 text-muted-foreground/50"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
</div>
<div className="min-w-0 flex-1">
<p className="text-[13px] font-semibold text-foreground/70">
{link.owner}/{link.repo}
</p>
</div>
<ExternalLink className="size-3.5 shrink-0 text-muted-foreground/25" />
</div>
) : (
<>
<div className="flex items-start gap-3">
<img
alt={repo.owner.login}
className="size-8 shrink-0 rounded-full ring-1 ring-border/20"
src={repo.owner.avatar_url}
/>
<div className="min-w-0 flex-1">
<p className="text-[13px] font-semibold text-foreground/90">
{repo.full_name}
</p>
{repo.description && (
<p className="mt-0.5 line-clamp-2 text-[12px] leading-relaxed text-muted-foreground/60">
{repo.description}
</p>
)}
</div>
<ExternalLink className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/20" />
</div>
<div className="mt-3 flex flex-wrap items-center gap-3 text-[11px] text-muted-foreground/50">
{repo.language && (
<span className="inline-flex items-center gap-1.5">
<span
className="inline-block size-2.5 rounded-full"
style={{ backgroundColor: languageColor(repo.language) }}
/>
{repo.language}
</span>
)}
{repo.stargazers_count > 0 && (
<span className="inline-flex items-center gap-1">
<Star className="size-3" />
{formatCount(repo.stargazers_count)}
</span>
)}
{repo.forks_count > 0 && (
<span className="inline-flex items-center gap-1">
<GitFork className="size-3" />
{formatCount(repo.forks_count)}
</span>
)}
{repo.license && (
<span className="inline-flex items-center gap-1">
<Circle className="size-1.5 fill-current" />
{repo.license.spdx_id}
</span>
)}
<span className="inline-flex items-center gap-1">
<Clock className="size-3" />
{repo.updated_at ? timeAgo(repo.updated_at) : ""}
</span>
</div>
{repo.topics.length > 0 && (
<div className="mt-2.5 flex flex-wrap gap-1.5">
{repo.topics.slice(0, 5).map((t) => (
<span
className="inline-block rounded-full bg-primary/[0.06] px-2 py-0.5 text-[10px] font-medium text-primary/70"
key={t}
>
{t}
</span>
))}
</div>
)}
</>
)}
</a>
);
}

View File

@ -0,0 +1,36 @@
/**
* Parse GitHub repository links from message content.
* Matches: https://github.com/owner/repo (with optional trailing path)
*/
const GITHUB_REPO_RE =
/(?:^|\s)(https?:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+))(?:\/[\w\-./]*)?(?=[\s?&#]|$)/g;
export interface GithubLinkMatch {
/** Full matched URL string */
url: string;
/** Repository owner (org or username) */
owner: string;
/** Repository name */
repo: string;
}
export function parseGithubLinks(text: string): GithubLinkMatch[] {
const results: GithubLinkMatch[] = [];
const seen = new Set<string>();
for (const match of text.matchAll(GITHUB_REPO_RE)) {
const url = match[1];
// Dedupe by URL
if (seen.has(url)) continue;
seen.add(url);
results.push({
url,
owner: match[2],
repo: match[3].replace(".git", ""),
});
}
return results;
}

View File

@ -126,6 +126,8 @@ export default function ChannelPage() {
hasMore={state.hasMore} hasMore={state.hasMore}
loading={state.loadingMessages} loading={state.loadingMessages}
messages={state.messages} messages={state.messages}
rooms={state.rooms.map((r) => ({ id: r.id, name: r.name, isPrivate: r.is_private }))}
workspaceId={state.currentRoom?.workspace_id}
onDelete={actions.handleDeleteMessage} onDelete={actions.handleDeleteMessage}
onEdit={actions.handleEditMessage} onEdit={actions.handleEditMessage}
onLoadMore={actions.handleLoadMore} onLoadMore={actions.handleLoadMore}

View File

@ -1,4 +1,4 @@
import { useCallback, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { Check, Copy, Link, Loader2, UserPlus } from "lucide-react"; import { Check, Copy, Link, Loader2, UserPlus } from "lucide-react";
import { api } from "@/client"; import { api } from "@/client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -26,8 +26,21 @@ export default function InviteDialog({ workspaceId, roomId, children }: Props) {
const [inviteLink, setInviteLink] = useState(""); const [inviteLink, setInviteLink] = useState("");
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const { toast } = useToast(); const { toast } = useToast();
useEffect(() => {
return () => {
if (copiedTimer.current) clearTimeout(copiedTimer.current);
};
}, []);
const markCopied = useCallback(() => {
setCopied(true);
if (copiedTimer.current) clearTimeout(copiedTimer.current);
copiedTimer.current = setTimeout(() => setCopied(false), 2000);
}, []);
const handleGenerate = useCallback(async () => { const handleGenerate = useCallback(async () => {
setGenerating(true); setGenerating(true);
try { try {
@ -42,13 +55,19 @@ export default function InviteDialog({ workspaceId, roomId, children }: Props) {
if (code) { if (code) {
const link = `${window.location.origin}/join/${code}`; const link = `${window.location.origin}/join/${code}`;
setInviteLink(link); setInviteLink(link);
try {
await navigator.clipboard.writeText(link); await navigator.clipboard.writeText(link);
setCopied(true); markCopied();
toast({ toast({
title: "Invite link copied", title: "Invite link copied",
description: "Share this link with your team", description: "Share this link with your team",
}); });
setTimeout(() => setCopied(false), 2000); } catch {
toast({
title: "Invite link created",
description: "Copy it manually from the input field.",
});
}
} }
} catch { } catch {
toast({ toast({
@ -59,19 +78,26 @@ export default function InviteDialog({ workspaceId, roomId, children }: Props) {
} finally { } finally {
setGenerating(false); setGenerating(false);
} }
}, [workspaceId, roomId, toast]); }, [workspaceId, roomId, markCopied, toast]);
const handleCopy = useCallback(async () => { const handleCopy = useCallback(async () => {
if (!inviteLink) return; if (!inviteLink) return;
try {
await navigator.clipboard.writeText(inviteLink); await navigator.clipboard.writeText(inviteLink);
setCopied(true); markCopied();
toast({ title: "Copied!" }); toast({ title: "Copied!" });
setTimeout(() => setCopied(false), 2000); } catch {
}, [inviteLink, toast]); toast({
title: "Copy failed",
description: "Please copy the invite link manually.",
variant: "destructive",
});
}
}, [inviteLink, markCopied, toast]);
return ( return (
<Dialog onOpenChange={setOpen} open={open}> <Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger>{children}</DialogTrigger> <DialogTrigger render={React.Children.only(children) as React.ReactElement} />
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base"> <DialogTitle className="flex items-center gap-2 text-base">

View File

@ -0,0 +1,187 @@
import { client } from "@/client";
import { formatMention } from "@/lib/ir/parser";
export type MentionRoomSuggestion = {
id: string;
name: string;
isPrivate: boolean;
};
export type MentionType = "all" | "room" | "repo" | "user";
export type MentionSuggestion = {
type: MentionType;
id: string;
label: string;
description?: string;
};
export type MentionToken = MentionSuggestion & {
start: number;
end: number;
};
export type MentionQuery = {
start: number;
end: number;
query: string;
};
export async function loadRemoteSuggestions(
query: string,
projectName: string,
workspaceId: string | undefined,
signal: AbortSignal,
): Promise<MentionSuggestion[]> {
const [repos, members] = await Promise.all([
loadRepoSuggestions(query, projectName, signal),
loadMemberSuggestions(query, workspaceId, signal),
]);
return [...repos, ...members];
}
export function findMentionQuery(text: string, caret: number): MentionQuery | null {
const prefix = text.slice(0, caret);
const match = prefix.match(/(^|\s)@([^\s@]*)$/u);
if (!match || match[2].includes("[")) return null;
const start = (match.index ?? 0) + match[1].length;
return { start, end: caret, query: match[2] };
}
export function serializeMentions(text: string, tokens: MentionToken[]) {
const ordered = [...tokens].sort((a, b) => a.start - b.start);
let cursor = 0;
let result = "";
for (const token of ordered) {
if (token.start < cursor) continue;
result += text.slice(cursor, token.start);
result += formatMention(token.type, token.id, sanitizeMentionLabel(token.label));
cursor = token.end;
}
return result + text.slice(cursor);
}
export function reconcileTokens(
oldText: string,
newText: string,
tokens: MentionToken[],
) {
const change = getChangeRange(oldText, newText);
const delta = newText.length - oldText.length;
return tokens.flatMap((token) => {
if (token.end <= change.start) return [token];
if (token.start >= change.oldEnd) {
return [{ ...token, start: token.start + delta, end: token.end + delta }];
}
return [];
});
}
export function shiftTokensForMention(
tokens: MentionToken[],
query: MentionQuery,
insertedLength: number,
nextToken: MentionToken,
) {
const delta = insertedLength - (query.end - query.start);
const shifted = tokens.flatMap((token) => {
if (token.end <= query.start) return [token];
if (token.start >= query.end) {
return [{ ...token, start: token.start + delta, end: token.end + delta }];
}
return [];
});
return [...shifted, nextToken].sort((a, b) => a.start - b.start);
}
export function nextIndex(index: number, count: number, key: string) {
return key === "ArrowDown"
? (index + 1) % count
: (index - 1 + count) % count;
}
export function deleteMentionAtCaret(
textarea: HTMLTextAreaElement,
text: string,
tokens: MentionToken[],
emitChange: (text: string, tokens: MentionToken[]) => void,
) {
const { selectionStart, selectionEnd } = textarea;
if (selectionStart !== selectionEnd) return false;
const token = tokens.find((item) => selectionStart > item.start && selectionStart <= item.end);
if (!token) return false;
const nextText = text.slice(0, token.start) + text.slice(token.end);
const delta = token.end - token.start;
const nextTokens = tokens
.filter((item) => item !== token)
.map((item) => item.start >= token.end
? { ...item, start: item.start - delta, end: item.end - delta }
: item);
emitChange(nextText, nextTokens);
requestAnimationFrame(() => textarea.setSelectionRange(token.start, token.start));
return true;
}
async function loadRepoSuggestions(query: string, projectName: string, signal: AbortSignal) {
if (!projectName) return [];
const { data } = await client.gitListRepos(
projectName,
{ search: query || undefined, limit: 5 },
{ signal },
);
return data.map((repo) => ({
type: "repo" as const,
id: repo.name,
label: repo.name,
description: "Repository",
}));
}
async function loadMemberSuggestions(
query: string,
workspaceId: string | undefined,
signal: AbortSignal,
) {
if (!workspaceId) return [];
const res = await client.workspaceListMembers(workspaceId, undefined, { signal });
const q = query.toLowerCase();
return res.data
.filter((member) => {
const displayName = member.display_name || member.username;
return displayName.toLowerCase().includes(q) || member.username.toLowerCase().includes(q);
})
.slice(0, 8)
.map((member) => ({
type: "user" as const,
id: member.username,
label: member.display_name || member.username,
description: `@${member.username}`,
}));
}
function sanitizeMentionLabel(label: string) {
return label.replace(/[:\]]/g, " ").trim() || "mention";
}
function getChangeRange(oldText: string, newText: string) {
let start = 0;
while (start < oldText.length && start < newText.length && oldText[start] === newText[start]) {
start++;
}
let oldEnd = oldText.length;
let newEnd = newText.length;
while (oldEnd > start && newEnd > start && oldText[oldEnd - 1] === newText[newEnd - 1]) {
oldEnd--;
newEnd--;
}
return { start, oldEnd, newEnd };
}

View File

@ -0,0 +1,341 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AtSign, GitBranch, Hash, Megaphone, User } from "lucide-react";
import { cn } from "@/lib/utils";
import {
deleteMentionAtCaret,
findMentionQuery,
loadRemoteSuggestions,
nextIndex,
reconcileTokens,
serializeMentions,
shiftTokensForMention,
type MentionQuery,
type MentionRoomSuggestion,
type MentionSuggestion,
type MentionToken,
type MentionType,
} from "./mention-textarea-utils";
export type { MentionRoomSuggestion } from "./mention-textarea-utils";
type Props = {
value: string;
rooms: MentionRoomSuggestion[];
projectName: string;
workspaceId?: string;
disabled?: boolean;
placeholder?: string;
onChange: (value: string) => void;
onSubmit: () => void;
};
const TYPE_ICON: Record<MentionType, typeof AtSign> = {
all: Megaphone,
room: Hash,
repo: GitBranch,
user: User,
};
const TYPE_LABEL: Record<MentionType, string> = {
all: "all",
room: "channel",
repo: "repo",
user: "user",
};
export default function MentionTextarea(props: Props) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [displayValue, setDisplayValue] = useState("");
const [tokens, setTokens] = useState<MentionToken[]>([]);
const [query, setQuery] = useState<MentionQuery | null>(null);
const [remoteSuggestions, setRemoteSuggestions] = useState<MentionSuggestion[]>([]);
const [loading, setLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
useEffect(() => {
if (!props.value) {
setDisplayValue("");
setTokens([]);
setQuery(null);
}
}, [props.value]);
const staticSuggestions = useMemo(
() => buildStaticSuggestions(props.rooms),
[props.rooms],
);
const suggestions = useMemo(
() => buildSuggestions(query, staticSuggestions, remoteSuggestions),
[query, remoteSuggestions, staticSuggestions],
);
useRemoteSuggestions({
query,
projectName: props.projectName,
workspaceId: props.workspaceId,
setLoading,
setRemoteSuggestions,
});
useEffect(() => {
setActiveIndex(0);
}, [query?.query]);
const emitChange = useCallback(
(nextDisplay: string, nextTokens: MentionToken[]) => {
setDisplayValue(nextDisplay);
setTokens(nextTokens);
props.onChange(serializeMentions(nextDisplay, nextTokens));
},
[props],
);
const updateQueryFromCaret = useCallback((text: string, caret: number) => {
setQuery(findMentionQuery(text, caret));
}, []);
const handleTextChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const nextDisplay = e.target.value;
const nextTokens = reconcileTokens(displayValue, nextDisplay, tokens);
emitChange(nextDisplay, nextTokens);
updateQueryFromCaret(nextDisplay, e.target.selectionStart);
},
[displayValue, emitChange, tokens, updateQueryFromCaret],
);
const insertMention = useCallback(
(item: MentionSuggestion) => {
if (!query) return;
const { nextDisplay, nextTokens, caret } = applyMention(displayValue, tokens, query, item);
setQuery(null);
emitChange(nextDisplay, nextTokens);
requestAnimationFrame(() => {
textareaRef.current?.focus();
textareaRef.current?.setSelectionRange(caret, caret);
});
},
[displayValue, emitChange, query, tokens],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (handleSuggestionKeys(e, query, suggestions, activeIndex, setActiveIndex, insertMention, setQuery)) return;
if (handleMentionBackspace(e, displayValue, tokens, emitChange, setQuery)) return;
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
props.onSubmit();
}
},
[activeIndex, displayValue, emitChange, insertMention, props, query, suggestions, tokens],
);
return (
<div className="relative">
<textarea
className="min-h-[52px] max-h-48 w-full resize-none border-0 bg-transparent px-5 py-3.5 pr-28 text-[13px] leading-relaxed text-foreground shadow-none outline-none placeholder:text-muted-foreground/35 focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-60"
disabled={props.disabled}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
onSelect={(e) => updateQueryFromCaret(displayValue, e.currentTarget.selectionStart)}
placeholder={props.placeholder}
ref={textareaRef}
rows={1}
value={displayValue}
/>
<MentionSuggestions
activeIndex={activeIndex}
loading={loading}
onSelect={insertMention}
open={Boolean(query)}
suggestions={suggestions}
/>
</div>
);
}
function MentionSuggestions({
activeIndex,
loading,
onSelect,
open,
suggestions,
}: {
activeIndex: number;
loading: boolean;
onSelect: (item: MentionSuggestion) => void;
open: boolean;
suggestions: MentionSuggestion[];
}) {
if (!open || (!loading && suggestions.length === 0)) return null;
return (
<div className="absolute bottom-full left-3 right-3 z-30 mb-2 overflow-hidden rounded-xl border border-border/60 bg-popover p-1 shadow-xl">
{suggestions.map((item, index) => (
<SuggestionRow
active={index === activeIndex}
item={item}
key={`${item.type}-${item.id}`}
onMouseDown={() => onSelect(item)}
/>
))}
{loading && suggestions.length === 0 && (
<div className="px-3 py-2 text-xs text-muted-foreground">Loading mentions</div>
)}
</div>
);
}
function SuggestionRow({
active,
item,
onMouseDown,
}: {
active: boolean;
item: MentionSuggestion;
onMouseDown: () => void;
}) {
const Icon = TYPE_ICON[item.type];
return (
<button
className={cn(
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors",
active ? "bg-accent text-accent-foreground" : "text-foreground hover:bg-accent/60",
)}
onMouseDown={(e) => {
e.preventDefault();
onMouseDown();
}}
type="button"
>
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{item.label}</span>
<span className="shrink-0 text-[11px] text-muted-foreground/60">
{item.description ?? TYPE_LABEL[item.type]}
</span>
</button>
);
}
function useRemoteSuggestions({
query,
projectName,
workspaceId,
setLoading,
setRemoteSuggestions,
}: {
query: MentionQuery | null;
projectName: string;
workspaceId?: string;
setLoading: (loading: boolean) => void;
setRemoteSuggestions: (items: MentionSuggestion[]) => void;
}) {
useEffect(() => {
if (!query) {
setRemoteSuggestions([]);
setLoading(false);
return;
}
const controller = new AbortController();
const timer = window.setTimeout(() => {
loadRemoteSuggestions(query.query, projectName, workspaceId, controller.signal)
.then((items) => !controller.signal.aborted && setRemoteSuggestions(items))
.catch(() => !controller.signal.aborted && setRemoteSuggestions([]))
.finally(() => !controller.signal.aborted && setLoading(false));
}, 150);
setLoading(true);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [projectName, query, setLoading, setRemoteSuggestions, workspaceId]);
}
function buildStaticSuggestions(rooms: MentionRoomSuggestion[]) {
return [
{ type: "all" as const, id: "everyone", label: "everyone", description: "Notify everyone" },
...rooms.map((room) => ({
type: "room" as const,
id: room.id,
label: room.name,
description: room.isPrivate ? "Private channel" : "Channel",
})),
];
}
function buildSuggestions(
query: MentionQuery | null,
staticSuggestions: MentionSuggestion[],
remoteSuggestions: MentionSuggestion[],
) {
if (!query) return [];
const q = query.query.toLowerCase();
const local = staticSuggestions.filter((item) =>
item.label.toLowerCase().includes(q),
);
return [...local, ...remoteSuggestions].slice(0, 10);
}
function applyMention(
displayValue: string,
tokens: MentionToken[],
query: MentionQuery,
item: MentionSuggestion,
) {
const mentionText = `@${item.label}`;
const insertText = `${mentionText} `;
const nextDisplay = `${displayValue.slice(0, query.start)}${insertText}${displayValue.slice(query.end)}`;
const nextToken = { ...item, start: query.start, end: query.start + mentionText.length };
const nextTokens = shiftTokensForMention(tokens, query, insertText.length, nextToken);
return { nextDisplay, nextTokens, caret: query.start + insertText.length };
}
function handleSuggestionKeys(
e: React.KeyboardEvent<HTMLTextAreaElement>,
query: MentionQuery | null,
suggestions: MentionSuggestion[],
activeIndex: number,
setActiveIndex: (fn: (idx: number) => number) => void,
insertMention: (item: MentionSuggestion) => void,
setQuery: (query: MentionQuery | null) => void,
) {
if (!query) return false;
if (e.key === "Escape") {
e.preventDefault();
setQuery(null);
return true;
}
if (suggestions.length === 0) return false;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((idx) => nextIndex(idx, suggestions.length, e.key));
return true;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
insertMention(suggestions[activeIndex] ?? suggestions[0]);
return true;
}
return false;
}
function handleMentionBackspace(
e: React.KeyboardEvent<HTMLTextAreaElement>,
displayValue: string,
tokens: MentionToken[],
emitChange: (text: string, tokens: MentionToken[]) => void,
setQuery: (query: MentionQuery | null) => void,
) {
if (e.key !== "Backspace") return false;
const deleted = deleteMentionAtCaret(e.currentTarget, displayValue, tokens, emitChange);
if (!deleted) return false;
e.preventDefault();
setQuery(null);
return true;
}

View File

@ -1,8 +1,14 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useNavigate, useParams } from "react-router";
import RepoEmbedCard from "./repo-embed-card"; import RepoEmbedCard from "./repo-embed-card";
import XEmbedCard from "./x-embed-card"; import XEmbedCard from "./x-embed-card";
import GithubEmbedCard from "./github-embed-card";
import { parseRepoLinks } from "./repo-link-parser"; import { parseRepoLinks } from "./repo-link-parser";
import { parseXLinks } from "./x-link-parser"; import { parseXLinks } from "./x-link-parser";
import { parseGithubLinks } from "./github-link-parser";
import { parseMentions } from "@/lib/ir/parser";
import { MentionChip } from "@/lib/ir/mention-chip";
import type { MentionData } from "@/lib/ir/parser";
type Props = { type Props = {
content: string; content: string;
@ -10,19 +16,109 @@ type Props = {
}; };
/** /**
* Renders message content, detecting same-origin repo links and * Renders message content, detecting:
* X/Twitter links, replacing them with embed cards. * - @[type:id:label] mentions rendered as clickable chips
* - Same-origin repo links embed cards
* - X/Twitter links embed cards
* - GitHub repo links embed cards
*/ */
export default function MessageContent({ content, contentType }: Props) { export default function MessageContent({ content, contentType }: Props) {
const navigate = useNavigate();
const { projectName = "" } = useParams();
const isPlainText = contentType === "text" || !contentType; const isPlainText = contentType === "text" || !contentType;
const elements = useMemo(() => { const elements = useMemo(() => {
const repoLinks = parseRepoLinks(content); // 1. Parse mentions
const xLinks = parseXLinks(content); const segments = parseMentions(content);
// If no mentions, fall back to link-parsing only
if (segments.length === 1 && segments[0].type === "text") {
// Just plain text + links
const repoLinks = parseRepoLinks(segments[0].content);
const xLinks = parseXLinks(segments[0].content);
const ghLinks = parseGithubLinks(segments[0].content);
if (repoLinks.length === 0 && xLinks.length === 0 && ghLinks.length === 0) {
return [
<p
className={
isPlainText
? "whitespace-pre-wrap break-words text-[13px] leading-[1.55] text-foreground/85"
: "whitespace-pre-wrap break-words text-[13px] text-foreground/85"
}
key="only"
>
{segments[0].content}
</p>,
];
}
// Fall through to old link-parsing for non-mention content
return buildLinkElements(segments[0].content, isPlainText);
}
// 2. Render segments: text segments as paragraphs, mentions as chips
const result: React.ReactNode[] = [];
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (seg.type === "text" && seg.content.trim()) {
// Check for links within text segments
const repoLinks = parseRepoLinks(seg.content);
const xLinks = parseXLinks(seg.content);
const ghLinks = parseGithubLinks(seg.content);
if (repoLinks.length > 0 || xLinks.length > 0 || ghLinks.length > 0) {
result.push(...buildLinkElements(seg.content, isPlainText));
} else {
result.push(
<p
className="whitespace-pre-wrap break-words text-[13px] leading-[1.55] text-foreground/85"
key={`t-${i}`}
>
{seg.content}
</p>,
);
}
} else if (seg.type === "mention" && seg.mention) {
const mention = seg.mention;
const handleClick = mention.entityType === "all" ? undefined : (m: MentionData) => {
if (m.entityType === "repo") {
navigate(`/${projectName}/repo/${m.entityId}`);
} else if (m.entityType === "room") {
navigate(`/${projectName}/channel/${m.entityId}`);
}
};
result.push(
<MentionChip
key={`m-${i}-${mention.entityId}`}
entityType={mention.entityType}
entityId={mention.entityId}
entityLabel={mention.entityLabel}
onClick={handleClick}
/>,
);
}
}
return result.length > 0 ? result : [<span key="empty" />];
}, [content, isPlainText, navigate, projectName]);
return <div className="flex flex-wrap items-center gap-1">{elements}</div>;
}
/** Build link embed elements from plain text. */
function buildLinkElements(
text: string,
isPlainText: boolean,
): React.ReactNode[] {
const repoLinks = parseRepoLinks(text);
const xLinks = parseXLinks(text);
const ghLinks = parseGithubLinks(text);
const allLinks = [ const allLinks = [
...repoLinks.map((l) => ({ kind: "repo" as const, url: l.url, data: l, index: content.indexOf(l.url) })), ...repoLinks.map((l) => ({ kind: "repo" as const, url: l.url, data: l, index: text.indexOf(l.url) })),
...xLinks.map((l) => ({ kind: "x" as const, url: l.url, data: l, index: content.indexOf(l.url) })), ...xLinks.map((l) => ({ kind: "x" as const, url: l.url, data: l, index: text.indexOf(l.url) })),
...ghLinks.map((l) => ({ kind: "github" as const, url: l.url, data: l, index: text.indexOf(l.url) })),
].sort((a, b) => a.index - b.index); ].sort((a, b) => a.index - b.index);
if (allLinks.length === 0) { if (allLinks.length === 0) {
@ -35,7 +131,7 @@ export default function MessageContent({ content, contentType }: Props) {
} }
key="only" key="only"
> >
{content} {text}
</p>, </p>,
]; ];
} }
@ -45,8 +141,8 @@ export default function MessageContent({ content, contentType }: Props) {
for (const link of allLinks) { for (const link of allLinks) {
if (link.index > cursor) { if (link.index > cursor) {
const text = content.slice(cursor, link.index); const beforeText = text.slice(cursor, link.index);
if (text.trim()) { if (beforeText.trim()) {
result.push( result.push(
<p <p
className={ className={
@ -56,7 +152,7 @@ export default function MessageContent({ content, contentType }: Props) {
} }
key={`t-${cursor}`} key={`t-${cursor}`}
> >
{text} {beforeText}
</p>, </p>,
); );
} }
@ -64,6 +160,8 @@ export default function MessageContent({ content, contentType }: Props) {
if (link.kind === "repo") { if (link.kind === "repo") {
result.push(<RepoEmbedCard key={`repo-${link.url}`} link={link.data} />); result.push(<RepoEmbedCard key={`repo-${link.url}`} link={link.data} />);
} else if (link.kind === "github") {
result.push(<GithubEmbedCard key={`gh-${link.url}`} link={link.data} />);
} else { } else {
result.push(<XEmbedCard key={`x-${link.url}`} link={link.data} />); result.push(<XEmbedCard key={`x-${link.url}`} link={link.data} />);
} }
@ -71,9 +169,9 @@ export default function MessageContent({ content, contentType }: Props) {
cursor = link.index + link.url.length; cursor = link.index + link.url.length;
} }
if (cursor < content.length) { if (cursor < text.length) {
const text = content.slice(cursor); const afterText = text.slice(cursor);
if (text.trim()) { if (afterText.trim()) {
result.push( result.push(
<p <p
className={ className={
@ -83,14 +181,11 @@ export default function MessageContent({ content, contentType }: Props) {
} }
key={`t-${cursor}`} key={`t-${cursor}`}
> >
{text} {afterText}
</p>, </p>,
); );
} }
} }
return result; return result;
}, [content, isPlainText]);
return <div>{elements}</div>;
} }

View File

@ -18,8 +18,10 @@ type Props = {
messages: MessageNewService[]; messages: MessageNewService[];
loading: boolean; loading: boolean;
hasMore: boolean; hasMore: boolean;
workspaceId?: string;
rooms?: { id: string; name: string; isPrivate: boolean }[];
onLoadMore: () => void; onLoadMore: () => void;
onSend: (content: string, inReplyTo?: string) => Promise<void>; onSend: (content: string, inReplyTo?: string, attachmentIds?: string[]) => Promise<void>;
onTyping?: (typing: boolean) => void; onTyping?: (typing: boolean) => void;
onPinToggle?: (messageId: string, pinned: boolean) => void; onPinToggle?: (messageId: string, pinned: boolean) => void;
onDelete?: (messageId: string) => void; onDelete?: (messageId: string) => void;
@ -74,6 +76,8 @@ export default function MessageView({
messages, messages,
loading, loading,
hasMore, hasMore,
workspaceId,
rooms: channelRooms,
onLoadMore, onLoadMore,
onSend, onSend,
onTyping, onTyping,
@ -371,9 +375,12 @@ export default function MessageView({
)} )}
<MessageComposer <MessageComposer
roomId={roomId}
rooms={channelRooms}
workspaceId={workspaceId}
onCancelReply={() => setReplyTarget(null)} onCancelReply={() => setReplyTarget(null)}
onSend={async (content) => { onSend={async (content, attachmentIds) => {
await onSend(content, replyTarget?.id); await onSend(content, replyTarget?.id, attachmentIds);
setReplyTarget(null); setReplyTarget(null);
}} }}
onTyping={onTyping} onTyping={onTyping}

View File

@ -1,46 +1,53 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
ExternalLink, GitFork,
Loader2, Loader2,
BookOpen, Star,
Clock, Clock,
Lock,
Globe,
} from "lucide-react"; } from "lucide-react";
import { api } from "@/client"; import { api } from "@/client";
import RepoDrawer from "./repo-drawer"; import RepoDrawer from "./repo-drawer";
import type { RepoLinkMatch } from "./repo-link-parser"; import type { RepoLinkMatch } from "./repo-link-parser";
type RepoInfo = { type RepoEmbedData = {
name: string; name: string;
description: string | null; description: string | null;
default_branch: string; default_branch: string;
language: string | null; visibility: string;
updated_at: string; updated_at: string;
language: string | null;
star_count: number;
fork_count: number;
topics: string[];
}; };
function languageColor(lang: string): string { function languageColor(lang: string): string {
const map: Record<string, string> = { const map: Record<string, string> = {
Rust: "#DEA584", Rust: "#DEA584", TypeScript: "#3178C6", JavaScript: "#F7DF1E",
TypeScript: "#3178C6", Python: "#3572A5", Go: "#00ADD8", Java: "#B07219",
JavaScript: "#F7DF1E", Kotlin: "#A97BFF", Swift: "#F05138", C: "#555555",
Python: "#3572A5", "C++": "#F34B7D", "C#": "#178600", Ruby: "#701516",
Go: "#00ADD8", Zig: "#EC915C", Elixir: "#6E4A7E", Haskell: "#5E5086",
Java: "#B07219", CSS: "#563D7C", HTML: "#E34C26", Shell: "#89E051",
Kotlin: "#A97BFF", PHP: "#4F5D95", Dart: "#00B4AB", Scala: "#C22D40",
Swift: "#F05138", R: "#198CE7", Lua: "#000080", Vue: "#41B883",
C: "#555555", Svelte: "#FF3E00", MDX: "#FCB32C", Dockerfile: "#384D54",
"C++": "#F34B7D", Makefile: "#427819", Markdown: "#083FA1", Nix: "#7E7EFF",
"C#": "#178600", OCaml: "#3BE133", "Objective-C": "#438EFF", Perl: "#0298C3",
Ruby: "#701516", Erlang: "#B83998", CMake: "#DA3434", PowerShell: "#012456",
Zig: "#EC915C", SQL: "#E38C00", Solidity: "#AA6746", Terraform: "#7B42BC",
Elixir: "#6E4A7E",
Haskell: "#5E5086",
CSS: "#563D7C",
HTML: "#E34C26",
Shell: "#89E051",
}; };
return map[lang] ?? "#6B7280"; return map[lang] ?? "#6B7280";
} }
function formatCount(n: number): string {
if (n === 0) return "";
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return n.toString();
}
function timeAgo(iso: string): string { function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime(); const diff = Date.now() - new Date(iso).getTime();
const days = Math.floor(diff / 86400000); const days = Math.floor(diff / 86400000);
@ -53,43 +60,21 @@ function timeAgo(iso: string): string {
} }
export default function RepoEmbedCard({ link }: { link: RepoLinkMatch }) { export default function RepoEmbedCard({ link }: { link: RepoLinkMatch }) {
const [info, setInfo] = useState<RepoInfo | null>(null); const [data, setData] = useState<RepoEmbedData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(false); const [error, setError] = useState(false);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch on mount
setLoading(true); setLoading(true);
setError(false);
const repoPath = `/api/v1/workspace/${link.workspace}/repos/${link.repo}`;
api api
.get<Record<string, unknown>>(repoPath) .get<RepoEmbedData>(
.then(async (repoRes) => { `/api/v1/workspace/${link.workspace}/repos/${link.repo}/embed-card`,
if (cancelled) return; )
const d = repoRes.data as Record<string, unknown>; .then((res) => {
// Fetch top language if (!cancelled) setData(res.data);
let topLang: string | null = null;
try {
const langRes = await api.get<
{ language: string; percent: number }[]
>(`${repoPath}/git/languages`);
if (!cancelled && langRes.data.length > 0) {
topLang = langRes.data[0].language;
}
} catch {
// language fetch is best-effort
}
if (cancelled) return;
setInfo({
name: (d.name as string) ?? link.repo,
description: (d.description as string) ?? null,
default_branch: (d.default_branch as string) ?? "main",
language: topLang,
updated_at: (d.updated_at as string) ?? "",
});
}) })
.catch(() => { .catch(() => {
if (!cancelled) setError(true); if (!cancelled) setError(true);
@ -105,60 +90,97 @@ export default function RepoEmbedCard({ link }: { link: RepoLinkMatch }) {
return ( return (
<RepoDrawer repo={link.repo} workspace={link.workspace}> <RepoDrawer repo={link.repo} workspace={link.workspace}>
<div className="mt-2 block max-w-[420px] rounded-xl border border-border/30 bg-muted/[0.03] p-4 transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-200 hover:border-primary/20 hover:bg-muted/[0.08] hover:shadow-sm"> <div
className="mt-2 block max-w-[420px] cursor-pointer rounded-xl border border-border/30 bg-muted/[0.03] p-4 transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-200 hover:border-primary/20 hover:bg-muted/[0.08] hover:shadow-sm"
>
{loading ? ( {loading ? (
<div className="flex items-center gap-2 py-2 text-[13px] text-muted-foreground/50"> <div className="flex items-center gap-2 py-2 text-[13px] text-muted-foreground/50">
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Loading repo info Loading repo info
</div> </div>
) : error || !info ? ( ) : error || !data ? (
<div className="flex items-center gap-2 py-2"> <div className="flex items-center gap-2 py-2">
<BookOpen className="size-4 shrink-0 text-muted-foreground/30" /> <div className="grid size-8 shrink-0 place-items-center rounded-lg bg-muted/40">
<div className="grid size-7 place-items-center rounded-md bg-gradient-to-br from-primary/30 to-primary/10 text-[10px] font-bold text-primary/70">
{link.repo.charAt(0).toUpperCase()}
</div>
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-[13px] font-semibold text-foreground/70"> <p className="text-[13px] font-semibold text-foreground/70">
{link.workspace}/{link.repo} {link.workspace}/
</p> <span className="text-primary/80">{link.repo}</span>
<p className="text-[11px] text-muted-foreground/40">
Click to open repository
</p> </p>
</div> </div>
<ExternalLink className="size-3.5 shrink-0 text-muted-foreground/25" />
</div> </div>
) : ( ) : (
<> <>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="grid size-8 shrink-0 place-items-center rounded-lg bg-muted/40"> <div className="grid size-8 shrink-0 place-items-center rounded-lg bg-muted/40">
<BookOpen className="size-4 text-muted-foreground/50" /> <div className="grid size-7 place-items-center rounded-md bg-gradient-to-br from-primary/30 to-primary/10 text-[10px] font-bold text-primary/70">
{data.name.charAt(0).toUpperCase()}
</div>
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-[13px] font-semibold text-foreground"> <p className="text-[13px] font-semibold text-foreground/90">
{link.workspace}/ {link.workspace}/
<span className="text-primary/80">{link.repo}</span> <span className="text-primary/80">{link.repo}</span>
</p> </p>
{info.description && ( {data.description && (
<p className="mt-0.5 line-clamp-2 text-[12px] leading-relaxed text-muted-foreground/60"> <p className="mt-0.5 line-clamp-2 text-[12px] leading-relaxed text-muted-foreground/60">
{info.description} {data.description}
</p> </p>
)} )}
</div> </div>
<ExternalLink className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/20" />
</div> </div>
<div className="mt-3 flex flex-wrap items-center gap-3 text-[11px] text-muted-foreground/50"> <div className="mt-3 flex flex-wrap items-center gap-3 text-[11px] text-muted-foreground/50">
{info.language && ( {data.language && (
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<span <span
className="inline-block size-2.5 rounded-full" className="inline-block size-2.5 rounded-full"
style={{ backgroundColor: languageColor(info.language) }} style={{ backgroundColor: languageColor(data.language) }}
/> />
{info.language} {data.language}
</span>
)}
{data.star_count > 0 && (
<span className="inline-flex items-center gap-1">
<Star className="size-3" />
{formatCount(data.star_count)}
</span>
)}
{data.fork_count > 0 && (
<span className="inline-flex items-center gap-1">
<GitFork className="size-3" />
{formatCount(data.fork_count)}
</span> </span>
)} )}
<span className="inline-flex items-center gap-1">
{data.visibility === "private" ? (
<Lock className="size-3" />
) : (
<Globe className="size-3" />
)}
{data.visibility === "private" ? "private" : "public"}
</span>
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<Clock className="size-3" /> <Clock className="size-3" />
{info.updated_at ? timeAgo(info.updated_at) : ""} {data.updated_at ? timeAgo(data.updated_at) : ""}
</span> </span>
</div> </div>
{data.topics.length > 0 && (
<div className="mt-2.5 flex flex-wrap gap-1.5">
{data.topics.slice(0, 5).map((t) => (
<span
className="inline-block rounded-full bg-primary/[0.06] px-2 py-0.5 text-[10px] font-medium text-primary/70"
key={t}
>
{t}
</span>
))}
</div>
)}
</> </>
)} )}
</div> </div>

View File

@ -50,7 +50,7 @@ export type ChannelState = {
}; };
export type ChannelActions = { export type ChannelActions = {
handleSend: (content: string, inReplyTo?: string) => Promise<void>; handleSend: (content: string, inReplyTo?: string, attachmentIds?: string[]) => Promise<void>;
handleLoadMore: () => void; handleLoadMore: () => void;
handleTyping: (typing: boolean) => void; handleTyping: (typing: boolean) => void;
handlePinToggle: (messageId: string, pinned: boolean) => void; handlePinToggle: (messageId: string, pinned: boolean) => void;
@ -79,6 +79,7 @@ export function useChannelState(roomId: string | undefined) {
const lastSeq = useRef(0); const lastSeq = useRef(0);
const prevRoomId = useRef<string | undefined>(undefined); const prevRoomId = useRef<string | undefined>(undefined);
const loadRequestSeq = useRef(0);
// Load threads for current room // Load threads for current room
const { data: threadsResponse } = useQuery({ const { data: threadsResponse } = useQuery({
@ -139,6 +140,9 @@ export function useChannelState(roomId: string | undefined) {
const loadMessages = useCallback( const loadMessages = useCallback(
async (targetRoomId: string, beforeSeq?: number) => { async (targetRoomId: string, beforeSeq?: number) => {
if (!targetRoomId) return; if (!targetRoomId) return;
const requestSeq = ++loadRequestSeq.current;
const isStaleRequest = () =>
requestSeq !== loadRequestSeq.current || targetRoomId !== prevRoomId.current;
setLoadingMessages(true); setLoadingMessages(true);
try { try {
const params: Record<string, string | number> = { limit: 50 }; const params: Record<string, string | number> = { limit: 50 };
@ -149,6 +153,8 @@ export function useChannelState(roomId: string | undefined) {
{ params }, { params },
); );
if (isStaleRequest()) return;
const result = (response.data as Record<string, unknown>)?.data as { messages?: MessageNewService[] } | undefined; const result = (response.data as Record<string, unknown>)?.data as { messages?: MessageNewService[] } | undefined;
if (result?.messages) { if (result?.messages) {
const allMsgs = result.messages as MessageNewService[]; const allMsgs = result.messages as MessageNewService[];
@ -170,7 +176,7 @@ export function useChannelState(roomId: string | undefined) {
} catch (err) { } catch (err) {
console.error("Failed to load messages:", err); console.error("Failed to load messages:", err);
} finally { } finally {
setLoadingMessages(false); if (!isStaleRequest()) setLoadingMessages(false);
} }
}, },
[messageCache], [messageCache],
@ -559,11 +565,12 @@ export function useChannelState(roomId: string | undefined) {
}, [roomId, currentUserId, onEvent, queryClient, toast, messageCache]); }, [roomId, currentUserId, onEvent, queryClient, toast, messageCache]);
const handleSend = useCallback( const handleSend = useCallback(
async (content: string, inReplyTo?: string) => { async (content: string, inReplyTo?: string, attachmentIds?: string[]) => {
if (!roomId) return; if (!roomId) return;
try { try {
const body: Record<string, unknown> = { content, content_type: "text" }; const body: Record<string, unknown> = { content, content_type: "text" };
if (inReplyTo) body.in_reply_to = inReplyTo; if (inReplyTo) body.in_reply_to = inReplyTo;
if (attachmentIds && attachmentIds.length > 0) body.attachment_ids = attachmentIds;
const res = await api.post( const res = await api.post(
`/api/v1/ws/rooms/${roomId}/messages`, `/api/v1/ws/rooms/${roomId}/messages`,
body, body,

View File

@ -80,10 +80,9 @@ export default function XEmbedCard({ link }: { link: XLinkMatch }) {
@{link.username} @{link.username}
</span> </span>
</div> </div>
<div <p className="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed text-muted-foreground/70">
className="mt-1 text-[12px] leading-relaxed text-muted-foreground/70 [&_a]:text-primary/70 [&_a]:no-underline [&_p]:my-1" {extractText(data.html)}
dangerouslySetInnerHTML={{ __html: extractText(data.html) }} </p>
/>
</div> </div>
<ExternalLink className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/20" /> <ExternalLink className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/20" />
</div> </div>
@ -95,11 +94,9 @@ export default function XEmbedCard({ link }: { link: XLinkMatch }) {
/** Extract plain text from the oEmbed HTML blockquote */ /** Extract plain text from the oEmbed HTML blockquote */
function extractText(html: string): string { function extractText(html: string): string {
// The oEmbed HTML is a <blockquote> with <p> tags inside. const doc = new DOMParser().parseFromString(html, "text/html");
// We strip the wrapper and keep the inner paragraphs. const blockquote = doc.querySelector("blockquote");
const match = html.match(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/); return (blockquote?.textContent ?? "").trim();
if (!match) return "";
return match[1].trim();
} }
function XIcon() { function XIcon() {

View File

@ -50,7 +50,7 @@ function AuthorAvatar({ author }: { author: IssueAuthor }) {
)} )}
> >
{author.avatar_url ? ( {author.avatar_url ? (
<img alt="" className="size-full object-cover" src={author.avatar_url} /> <img alt={name + " 的头像"} className="size-full object-cover" src={author.avatar_url} />
) : ( ) : (
workspaceInitial(name) workspaceInitial(name)
)} )}

View File

@ -53,7 +53,7 @@ function AuthorAvatar({ author }: { author: { username: string; avatar_url?: str
)} )}
> >
{author.avatar_url ? ( {author.avatar_url ? (
<img alt="" className="size-full object-cover" src={author.avatar_url} /> <img alt={name + " 的头像"} className="size-full object-cover" src={author.avatar_url} />
) : ( ) : (
workspaceInitial(name) workspaceInitial(name)
)} )}

View File

@ -50,7 +50,7 @@ function AuthorAvatar({ author }: { author: { username: string; avatar_url?: str
)} )}
> >
{author.avatar_url ? ( {author.avatar_url ? (
<img alt="" className="size-full object-cover" src={author.avatar_url} /> <img alt={name + " 的头像"} className="size-full object-cover" src={author.avatar_url} />
) : ( ) : (
workspaceInitial(name) workspaceInitial(name)
)} )}

View File

@ -0,0 +1,232 @@
import { useCallback, useMemo, useState } from "react";
import { Link, Navigate, useLocation, useParams } from "react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, Clock, Loader2, UserPlus, XCircle } from "lucide-react";
import { client } from "@/client";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/context/auth-context";
import NavShell from "@/components/shell/rail";
function AccessMessage({
actionHref,
actionText,
description,
title,
}: {
actionHref?: string;
actionText?: string;
description: string;
title: string;
}) {
return (
<main className="grid min-h-screen place-items-center bg-background px-4">
<section className="w-full max-w-md rounded-2xl border border-border bg-card p-6 text-center shadow-sm">
<h1 className="text-base font-semibold text-foreground">{title}</h1>
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
{actionHref && actionText && (
<Button asChild className="mt-5">
<Link to={actionHref}>{actionText}</Link>
</Button>
)}
</section>
</main>
);
}
export default function WorkspaceJoinApplyPage() {
const { projectName = "" } = useParams();
const location = useLocation();
const queryClient = useQueryClient();
const { isAuthenticated, isLoading: authLoading } = useAuth();
const [answer, setAnswer] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const { data: strategy, isLoading } = useQuery({
queryKey: ["workspace", projectName, "join-strategy"],
queryFn: async () => {
const res = await client.workspaceJoinStrategy(projectName);
return res.data;
},
enabled: isAuthenticated && Boolean(projectName),
retry: false,
});
const { data: myApplies = [] } = useQuery({
queryKey: ["workspace", "join", "my-applies"],
queryFn: async () => {
const res = await client.workspaceMyJoinApplies();
return res.data;
},
enabled: isAuthenticated,
retry: false,
});
const currentApply = useMemo(
() => myApplies.find((item) => item.workspace_name === projectName),
[myApplies, projectName],
);
const applyJoin = useMutation({
mutationFn: async () => {
const res = await client.workspaceApplyJoin(projectName, {
answer: answer.trim() || null,
message: message.trim() || null,
});
return res.data;
},
onSuccess: async () => {
setError("");
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["workspace", "join", "my-applies"] }),
queryClient.invalidateQueries({ queryKey: ["workspace", projectName] }),
]);
},
onError: (err) => {
setError(err instanceof Error ? err.message : "Failed to submit join request.");
},
});
const cancelJoin = useMutation({
mutationFn: async () => {
await client.workspaceCancelJoin(projectName);
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["workspace", "join", "my-applies"] }),
});
const submit = useCallback(() => {
setError("");
applyJoin.mutate();
}, [applyJoin]);
if (!projectName) return <Navigate replace to="/" />;
if (authLoading) {
return <AccessMessage description="Checking login status…" title="Loading" />;
}
if (!isAuthenticated) {
return (
<AccessMessage
actionHref={`/auth/login?redirect=${encodeURIComponent(location.pathname + location.search)}`}
actionText="去登录"
description="登录后才能申请加入 workspace。未登录状态不会展示 workspace 相关数据。"
title="请先登录"
/>
);
}
const pending = currentApply?.status === "pending";
const approved = currentApply?.status === "approved";
return (
<NavShell>
<main className="grid h-svh place-items-center bg-background px-4">
<section className="w-full max-w-lg rounded-2xl border border-border bg-card p-6 shadow-sm">
<div className="mb-5 flex items-center gap-3">
<div className="grid size-10 place-items-center rounded-xl bg-primary/10 text-primary">
<UserPlus className="size-5" />
</div>
<div>
<h1 className="text-base font-semibold text-foreground">Join {projectName}</h1>
<p className="text-sm text-muted-foreground">Request access to this workspace.</p>
</div>
</div>
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" /> Loading join settings
</div>
) : !strategy?.enabled ? (
<StatusBlock
icon={<XCircle className="size-4" />}
title="Join requests are disabled"
tone="destructive"
/>
) : pending ? (
<div className="space-y-4">
<StatusBlock
icon={<Clock className="size-4" />}
title="Your request is pending admin approval."
tone="warning"
/>
<Button disabled={cancelJoin.isPending} onClick={() => cancelJoin.mutate()} variant="outline">
Cancel request
</Button>
</div>
) : approved ? (
<div className="space-y-4">
<StatusBlock
icon={<CheckCircle2 className="size-4" />}
title="You are approved for this workspace."
tone="success"
/>
<Button asChild>
<Link to={`/${projectName}/repos`}>Open workspace</Link>
</Button>
</div>
) : (
<div className="space-y-4">
{strategy.require_question && (
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="join-answer">
{strategy.question ?? "Answer the join question"}
</label>
<Textarea
id="join-answer"
onChange={(e) => setAnswer(e.target.value)}
placeholder="Your answer"
value={answer}
/>
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="join-message">
Message <span className="font-normal text-muted-foreground">optional</span>
</label>
<Textarea
id="join-message"
onChange={(e) => setMessage(e.target.value)}
placeholder="Tell admins why you want to join"
value={message}
/>
</div>
{error && <p className="rounded-lg bg-destructive/10 px-4 py-3 text-sm text-destructive">{error}</p>}
<Button
disabled={applyJoin.isPending || (strategy.require_question && !answer.trim())}
onClick={submit}
>
{applyJoin.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
Submit request
</Button>
</div>
)}
</section>
</main>
</NavShell>
);
}
function StatusBlock({
icon,
title,
tone,
}: {
icon: React.ReactNode;
title: string;
tone: "success" | "warning" | "destructive";
}) {
const className = {
success: "bg-emerald-500/10 text-emerald-600",
warning: "bg-amber-500/10 text-amber-600",
destructive: "bg-destructive/10 text-destructive",
}[tone];
return (
<div className={`flex items-start gap-3 rounded-xl px-4 py-3 text-sm ${className}`}>
<span className="mt-0.5 shrink-0">{icon}</span>
<span>{title}</span>
</div>
);
}

View File

@ -1,3 +1,4 @@
import { useMemo } from "react";
import { useParams } from "react-router"; import { useParams } from "react-router";
import { NavLink, Outlet, Navigate } from "react-router"; import { NavLink, Outlet, Navigate } from "react-router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@ -111,10 +112,13 @@ export default function RepoLayout() {
retry: false, retry: false,
}); });
const allTabs = [ const allTabs = useMemo(
() => [
...(readme?.html ? [{ label: "README", to: "readme" }] : []), ...(readme?.html ? [{ label: "README", to: "readme" }] : []),
...tabs, ...tabs,
]; ],
[readme?.html],
);
if (isLoading) { if (isLoading) {
return ( return (

View File

@ -31,7 +31,7 @@ function AuthorAvatar({ author }: { author: { username: string; display_name?: s
<span <span
className={cn("grid size-6 place-items-center overflow-hidden rounded-full bg-gradient-to-br text-[10px] font-bold text-white", workspaceColor(name))} className={cn("grid size-6 place-items-center overflow-hidden rounded-full bg-gradient-to-br text-[10px] font-bold text-white", workspaceColor(name))}
> >
{author.avatar_url ? <img alt="" className="size-full object-cover" src={author.avatar_url} /> : workspaceInitial(name)} {author.avatar_url ? <img alt={name + " 的头像"} className="size-full object-cover" src={author.avatar_url} /> : workspaceInitial(name)}
</span> </span>
); );
} }

View File

@ -37,7 +37,7 @@ function AuthorAvatar({ author }: { author: { username: string; avatar_url?: str
)} )}
> >
{author.avatar_url ? ( {author.avatar_url ? (
<img alt="" className="size-full object-cover" src={author.avatar_url} /> <img alt={name + " 的头像"} className="size-full object-cover" src={author.avatar_url} />
) : ( ) : (
workspaceInitial(name) workspaceInitial(name)
)} )}

View File

@ -1,7 +1,9 @@
import { useMemo } from "react";
import { useParams } from "react-router"; import { useParams } from "react-router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { client } from "@/client"; import { client } from "@/client";
import { BookOpen } from "lucide-react"; import { BookOpen } from "lucide-react";
import { sanitizeHtml } from "@/lib/sanitize-html";
const SKELETON_WIDTHS = Array.from({ length: 8 }, () => 30 + Math.floor(Math.random() * 60)); const SKELETON_WIDTHS = Array.from({ length: 8 }, () => 30 + Math.floor(Math.random() * 60));
@ -18,11 +20,16 @@ export default function ReadmePage() {
retry: false, retry: false,
}); });
const safeReadmeHtml = useMemo(
() => sanitizeHtml(readme?.html ?? ""),
[readme?.html],
);
if (isLoading) { if (isLoading) {
return <div className="space-y-3">{SKELETON_WIDTHS.map((w, i) => <div className="h-4 animate-pulse rounded bg-muted/50" key={i} style={{ width: `${w}%` }} />)}</div>; return <div className="space-y-3">{SKELETON_WIDTHS.map((w, i) => <div className="h-4 animate-pulse rounded bg-muted/50" key={i} style={{ width: `${w}%` }} />)}</div>;
} }
if (!readme?.html) { if (!safeReadmeHtml) {
return ( return (
<div className="py-12 text-center"> <div className="py-12 text-center">
<BookOpen className="mx-auto size-5 text-muted-foreground/20" /> <BookOpen className="mx-auto size-5 text-muted-foreground/20" />
@ -40,7 +47,7 @@ export default function ReadmePage() {
</div> </div>
<div <div
className="px-6 py-4 text-[14px] leading-relaxed [&_h1]:text-lg [&_h1]:font-bold [&_h1]:font-heading [&_h1]:mb-3 [&_h1]:mt-6 [&_h1:first-child]:mt-0 [&_h2]:text-base [&_h2]:font-bold [&_h2]:font-heading [&_h2]:mb-2 [&_h2]:mt-5 [&_h3]:text-[15px] [&_h3]:font-bold [&_h3]:mb-2 [&_h3]:mt-4 [&_h4]:text-[14px] [&_h4]:font-bold [&_h4]:mb-1 [&_h4]:mt-3 [&_p]:mb-3 [&_p:last-child]:mb-0 [&_a]:text-primary [&_a]:underline [&_a:hover]:opacity-80 [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:mb-3 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:mb-3 [&_li]:mb-1 [&_code]:rounded-sm [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[12px] [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-4 [&_pre]:mb-3 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-[12px] [&_blockquote]:border-l-2 [&_blockquote]:border-primary/30 [&_blockquote]:pl-4 [&_blockquote]:py-0.5 [&_blockquote]:mb-3 [&_blockquote]:text-muted-foreground [&_img]:rounded-md [&_img]:max-w-full [&_img]:my-3 [&_hr]:border-border [&_hr]:my-4 [&_table]:w-full [&_table]:border-collapse [&_table]:mb-3 [&_th]:border [&_th]:border-border [&_th]:px-3 [&_th]:py-2 [&_th]:bg-muted/50 [&_th]:text-left [&_th]:font-heading [&_th]:text-[13px] [&_td]:border [&_td]:border-border [&_td]:px-3 [&_td]:py-2" className="px-6 py-4 text-[14px] leading-relaxed [&_h1]:text-lg [&_h1]:font-bold [&_h1]:font-heading [&_h1]:mb-3 [&_h1]:mt-6 [&_h1:first-child]:mt-0 [&_h2]:text-base [&_h2]:font-bold [&_h2]:font-heading [&_h2]:mb-2 [&_h2]:mt-5 [&_h3]:text-[15px] [&_h3]:font-bold [&_h3]:mb-2 [&_h3]:mt-4 [&_h4]:text-[14px] [&_h4]:font-bold [&_h4]:mb-1 [&_h4]:mt-3 [&_p]:mb-3 [&_p:last-child]:mb-0 [&_a]:text-primary [&_a]:underline [&_a:hover]:opacity-80 [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:mb-3 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:mb-3 [&_li]:mb-1 [&_code]:rounded-sm [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[12px] [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-4 [&_pre]:mb-3 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-[12px] [&_blockquote]:border-l-2 [&_blockquote]:border-primary/30 [&_blockquote]:pl-4 [&_blockquote]:py-0.5 [&_blockquote]:mb-3 [&_blockquote]:text-muted-foreground [&_img]:rounded-md [&_img]:max-w-full [&_img]:my-3 [&_hr]:border-border [&_hr]:my-4 [&_table]:w-full [&_table]:border-collapse [&_table]:mb-3 [&_th]:border [&_th]:border-border [&_th]:px-3 [&_th]:py-2 [&_th]:bg-muted/50 [&_th]:text-left [&_th]:font-heading [&_th]:text-[13px] [&_td]:border [&_td]:border-border [&_td]:px-3 [&_td]:py-2"
dangerouslySetInnerHTML={{ __html: readme.html }} dangerouslySetInnerHTML={{ __html: safeReadmeHtml }}
/> />
</div> </div>
</div> </div>

View File

@ -24,7 +24,7 @@ const GroupRow = memo(function GroupRow({
)} )}
> >
{group.avatar_url ? ( {group.avatar_url ? (
<img alt="" className="size-full object-cover" src={group.avatar_url} /> <img alt={group.name + " 的头像"} className="size-full object-cover" src={group.avatar_url} />
) : ( ) : (
workspaceInitial(group.name) workspaceInitial(group.name)
)} )}

View File

@ -1,4 +1,4 @@
import { useState, useId, type FormEvent } from "react"; import { useEffect, useState, useId, type FormEvent } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { client } from "@/client"; import { client } from "@/client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -40,10 +40,18 @@ export default function JoinTab({ projectName }: { projectName: string }) {
retry: false, retry: false,
}); });
const [enabled, setEnabled] = useState(strategy?.enabled ?? false); const [enabled, setEnabled] = useState(false);
const [requireApproval, setRequireApproval] = useState(strategy?.require_approval ?? true); const [requireApproval, setRequireApproval] = useState(true);
const [requireQuestion, setRequireQuestion] = useState(strategy?.require_question ?? false); const [requireQuestion, setRequireQuestion] = useState(false);
const [question, setQuestion] = useState(strategy?.question ?? ""); const [question, setQuestion] = useState("");
useEffect(() => {
if (!strategy) return;
setEnabled(strategy.enabled);
setRequireApproval(strategy.require_approval);
setRequireQuestion(strategy.require_question);
setQuestion(strategy.question ?? "");
}, [strategy]);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => { const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
@ -51,16 +59,17 @@ export default function JoinTab({ projectName }: { projectName: string }) {
setSubmitting(true); setSubmitting(true);
const form = new FormData(event.currentTarget); const form = new FormData(event.currentTarget);
const answer = (form.get("answer") as string) || null; const rawAnswer = ((form.get("answer") as string) || "").trim();
const payload = {
try {
await client.workspaceUpdateJoinStrategy(projectName, {
enabled, enabled,
require_approval: requireApproval, require_approval: requireApproval,
require_question: requireQuestion, require_question: requireQuestion,
question: requireQuestion ? question : null, question: requireQuestion ? question : null,
answer, ...(rawAnswer || !strategy?.has_answer ? { answer: rawAnswer || null } : {}),
}); };
try {
await client.workspaceUpdateJoinStrategy(projectName, payload);
await qc.invalidateQueries({ queryKey: ["workspace", projectName, "join-strategy"] }); await qc.invalidateQueries({ queryKey: ["workspace", projectName, "join-strategy"] });
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to update join strategy."); setError(err instanceof Error ? err.message : "Failed to update join strategy.");
@ -73,7 +82,19 @@ export default function JoinTab({ projectName }: { projectName: string }) {
mutationFn: async ({ username, approved }: { username: string; approved: boolean }) => { mutationFn: async ({ username, approved }: { username: string; approved: boolean }) => {
await client.workspaceApproveJoin(projectName, username, { approved, reason: null }); await client.workspaceApproveJoin(projectName, username, { approved, reason: null });
}, },
onSuccess: () => qc.invalidateQueries({ queryKey: ["workspace", projectName, "join-applies"] }), onSuccess: async (_data, variables) => {
await Promise.all([
qc.invalidateQueries({ queryKey: ["workspace", projectName, "join-applies"] }),
qc.invalidateQueries({ queryKey: ["workspace", projectName, "members"] }),
qc.invalidateQueries({ queryKey: ["workspace", "join", "my-applies"] }),
qc.invalidateQueries({ queryKey: ["workspace", "join-applies"] }),
]);
if (!variables.approved) return;
await qc.invalidateQueries({ queryKey: ["workspace", projectName] });
},
onError: (err) => {
setError(err instanceof Error ? err.message : "Failed to update join request.");
},
}); });
if (isLoading) return <FormSkeleton />; if (isLoading) return <FormSkeleton />;
@ -146,7 +167,6 @@ export default function JoinTab({ projectName }: { projectName: string }) {
/> />
</div> </div>
{strategy?.has_answer && (
<div> <div>
<label className="text-sm font-medium text-foreground" htmlFor={aId}> <label className="text-sm font-medium text-foreground" htmlFor={aId}>
Expected answer <span className="font-normal text-muted-foreground"> optional</span> Expected answer <span className="font-normal text-muted-foreground"> optional</span>
@ -155,10 +175,9 @@ export default function JoinTab({ projectName }: { projectName: string }) {
className="mt-2 h-10 text-sm" className="mt-2 h-10 text-sm"
id={aId} id={aId}
name="answer" name="answer"
placeholder="Answer that auto-approves requests" placeholder={strategy?.has_answer ? "Leave blank to keep current answer" : "Answer that auto-approves requests"}
/> />
</div> </div>
)}
</> </>
)} )}
</div> </div>
@ -201,6 +220,12 @@ export default function JoinTab({ projectName }: { projectName: string }) {
{apply.message && ( {apply.message && (
<p className="mt-0.5 truncate text-xs text-muted-foreground">{apply.message}</p> <p className="mt-0.5 truncate text-xs text-muted-foreground">{apply.message}</p>
)} )}
{apply.question && (
<p className="mt-1 text-xs text-muted-foreground/70">
<span className="font-medium">Q:</span> {apply.question}
{apply.answer && <span> · <span className="font-medium">A:</span> {apply.answer}</span>}
</p>
)}
</div> </div>
<span className="shrink-0 text-xs text-muted-foreground"> <span className="shrink-0 text-xs text-muted-foreground">
{new Date(apply.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })} {new Date(apply.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })}
@ -209,6 +234,7 @@ export default function JoinTab({ projectName }: { projectName: string }) {
<Button <Button
aria-label={`Approve ${apply.username}`} aria-label={`Approve ${apply.username}`}
className="h-8 px-3 text-xs" className="h-8 px-3 text-xs"
disabled={approveApply.isPending}
onClick={() => approveApply.mutate({ username: apply.username, approved: true })} onClick={() => approveApply.mutate({ username: apply.username, approved: true })}
size="sm" size="sm"
type="button" type="button"
@ -218,6 +244,7 @@ export default function JoinTab({ projectName }: { projectName: string }) {
<Button <Button
aria-label={`Reject ${apply.username}`} aria-label={`Reject ${apply.username}`}
className="h-8 px-3 text-xs" className="h-8 px-3 text-xs"
disabled={approveApply.isPending}
onClick={() => approveApply.mutate({ username: apply.username, approved: false })} onClick={() => approveApply.mutate({ username: apply.username, approved: false })}
size="sm" size="sm"
type="button" type="button"

View File

@ -34,7 +34,7 @@ const MemberRow = memo(function MemberRow({
)} )}
> >
{member.avatar_url ? ( {member.avatar_url ? (
<img alt="" className="size-full object-cover" src={member.avatar_url} /> <img alt={name + " 的头像"} className="size-full object-cover" src={member.avatar_url} />
) : ( ) : (
workspaceInitial(name) workspaceInitial(name)
)} )}

View File

@ -501,6 +501,8 @@ function ChatInner({
handleStop: () => void; handleStop: () => void;
}) { }) {
const { textInput } = usePromptInputController(); const { textInput } = usePromptInputController();
const textInputRef = useRef(textInput);
textInputRef.current = textInput;
const allMessages = useMemo( const allMessages = useMemo(
() => messageGroups.flatMap((g) => g.messages), () => messageGroups.flatMap((g) => g.messages),
[messageGroups], [messageGroups],
@ -510,26 +512,27 @@ function ChatInner({
(text: string) => { (text: string) => {
if (text.trim()) { if (text.trim()) {
sendMessage(text); sendMessage(text);
textInput.clear(); textInputRef.current.clear();
} }
}, },
[sendMessage, textInput], [sendMessage],
); );
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => { (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const ti = textInputRef.current;
// ---- Mention-aware backspace ---- // ---- Mention-aware backspace ----
if (e.key === "Backspace") { if (e.key === "Backspace") {
const ta = e.currentTarget; const ta = e.currentTarget;
const { selectionStart, selectionEnd } = ta; const { selectionStart, selectionEnd } = ta;
if (selectionStart === selectionEnd && selectionStart > 0) { if (selectionStart === selectionEnd && selectionStart > 0) {
const hit = mentionAtCursor(textInput.value, selectionStart); const hit = mentionAtCursor(ti.value, selectionStart);
if (hit) { if (hit) {
e.preventDefault(); e.preventDefault();
const newText = const newText =
textInput.value.slice(0, hit.start) + ti.value.slice(0, hit.start) +
textInput.value.slice(hit.end); ti.value.slice(hit.end);
textInput.setInput(newText); ti.setInput(newText);
// Restore cursor to the position where the mention was. // Restore cursor to the position where the mention was.
requestAnimationFrame(() => { requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = hit.start; ta.selectionStart = ta.selectionEnd = hit.start;
@ -542,14 +545,14 @@ function ChatInner({
// ---- Enter to submit ---- // ---- Enter to submit ----
if (e.key === "Enter" && !e.shiftKey && !sending) { if (e.key === "Enter" && !e.shiftKey && !sending) {
e.preventDefault(); e.preventDefault();
const text = textInput.value; const text = ti.value;
if (text.trim()) { if (text.trim()) {
sendMessage(text); sendMessage(text);
textInput.clear(); ti.clear();
} }
} }
}, },
[sendMessage, textInput, sending], [sendMessage, sending],
); );
return ( return (

4
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;
declare const __APP_ENV__: string;