From 9bc0e742bceaff82a8d9ecb341af926fbbc8f2fa Mon Sep 17 00:00:00 2001 From: zhenyi <434836402@qq.com> Date: Mon, 1 Jun 2026 22:04:53 +0800 Subject: [PATCH] refactor: update frontend components and pages --- src/App.tsx | 121 ++++--- src/app/root-layout.tsx | 24 +- src/components/CommandPalette.tsx | 27 +- src/components/ErrorBoundary.tsx | 71 ++++ .../ai-elements/mention-textarea-overlay.tsx | 5 +- src/components/repo/repo-view.tsx | 13 +- src/components/settings/SettingsModal.tsx | 24 +- src/components/shell/personal-sidebar.tsx | 2 +- src/components/shell/rail.tsx | 2 +- .../shell/workspace-sidebar-inner.tsx | 2 +- src/components/shell/workspace-sidebar.tsx | 122 ++++++- src/context/auth-context.tsx | 38 ++ src/faro.tsx | 144 ++++++++ src/hooks/use-faro-measure.ts | 26 ++ src/lib/ir/mention-chip.tsx | 4 +- src/lib/sanitize-html.ts | 105 ++++++ src/main.tsx | 1 + src/page/auth/login.tsx | 10 +- src/page/join-invite.tsx | 136 +++++++ src/page/landing/home.tsx | 12 +- src/page/me/chat-conversation.tsx | 17 +- src/page/me/following.tsx | 2 +- src/page/me/overview/activity.tsx | 2 +- src/page/me/overview/index.tsx | 17 +- src/page/me/repos.tsx | 2 +- src/page/settings/security.tsx | 66 +++- src/page/workspace/channel/composer.tsx | 154 ++++++-- src/page/workspace/channel/file-upload.tsx | 75 +++- .../workspace/channel/github-embed-card.tsx | 223 ++++++++++++ .../workspace/channel/github-link-parser.ts | 36 ++ src/page/workspace/channel/index.tsx | 2 + src/page/workspace/channel/invite-dialog.tsx | 56 ++- .../channel/mention-textarea-utils.ts | 187 ++++++++++ .../workspace/channel/mention-textarea.tsx | 341 ++++++++++++++++++ .../workspace/channel/message-content.tsx | 191 +++++++--- src/page/workspace/channel/message-view.tsx | 13 +- .../workspace/channel/repo-embed-card.tsx | 212 ++++++----- .../workspace/channel/use-channel-state.ts | 13 +- src/page/workspace/channel/x-embed-card.tsx | 15 +- src/page/workspace/issues/comment-section.tsx | 2 +- src/page/workspace/issues/detail.tsx | 2 +- src/page/workspace/issues/index.tsx | 2 +- src/page/workspace/join-apply.tsx | 232 ++++++++++++ src/page/workspace/repo/layout.tsx | 12 +- src/page/workspace/repo/pull-detail.tsx | 2 +- src/page/workspace/repo/pulls.tsx | 2 +- src/page/workspace/repo/readme-page.tsx | 11 +- src/page/workspace/settings/groups.tsx | 2 +- src/page/workspace/settings/join.tsx | 81 +++-- src/page/workspace/settings/members.tsx | 2 +- .../workspace/workplan/chat-conversation.tsx | 21 +- src/vite-env.d.ts | 4 + 52 files changed, 2503 insertions(+), 385 deletions(-) create mode 100644 src/components/ErrorBoundary.tsx create mode 100644 src/faro.tsx create mode 100644 src/hooks/use-faro-measure.ts create mode 100644 src/lib/sanitize-html.ts create mode 100644 src/page/join-invite.tsx create mode 100644 src/page/workspace/channel/github-embed-card.tsx create mode 100644 src/page/workspace/channel/github-link-parser.ts create mode 100644 src/page/workspace/channel/mention-textarea-utils.ts create mode 100644 src/page/workspace/channel/mention-textarea.tsx create mode 100644 src/page/workspace/join-apply.tsx create mode 100644 src/vite-env.d.ts diff --git a/src/App.tsx b/src/App.tsx index 8079d24..3918f07 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,69 +1,86 @@ -import { useEffect } from "react"; +import { useEffect, lazy, useRef } from "react"; import { Navigate, createBrowserRouter, useParams } from "react-router"; import { RouterProvider } from "react-router/dom"; 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 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 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() { const { projectName = "" } = useParams(); - return ; } function App() { + const mountRef = useRef(performance.now()); + useEffect(() => { const id = getSavedThemeId(); const preset = getThemeById(id) || getThemeById(defaultThemeId)!; applyTheme(preset); }, []); + useEffect(() => { + pushFaroMeasurement('app_render', { + timeToInteractiveMs: Math.round(performance.now() - mountRef.current), + }); + }, []); + const router = createBrowserRouter([ { element: , @@ -89,6 +106,14 @@ function App() { }, ], }, + { + path: "/join/:code", + element: , + }, + { + path: "/:projectName/join", + element: , + }, { path: "/workspace/:projectName/*", element: , diff --git a/src/app/root-layout.tsx b/src/app/root-layout.tsx index 1e98744..b3a38ad 100644 --- a/src/app/root-layout.tsx +++ b/src/app/root-layout.tsx @@ -1,10 +1,28 @@ +import { Suspense } from "react"; import { Outlet } from "react-router"; import { SettingsModalProvider } from "@/components/settings/SettingsModalContext"; +import { ErrorBoundary } from "@/components/ErrorBoundary"; + +/** Minimal skeleton shown while lazy route chunks load. */ +function RouteFallback() { + return ( +
+
+
+ Loading… +
+
+ ); +} export default function RootLayout() { return ( - - - + + + }> + + + + ); } diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index a8a0aa9..abc51ef 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -135,26 +135,39 @@ export function CommandPalette() { }, [open]); // Fetch data - const fetchData = useCallback(async (q: string) => { + const fetchData = useCallback(async (q: string, signal: AbortSignal) => { setLoading(true); setError(null); try { const { data } = await api.get("/api/v1/search", { params: q.trim() ? { q: q.trim() } : {}, + signal, }); - setItems(buildHits(data)); + if (!signal.aborted) setItems(buildHits(data)); } catch (err) { + if (signal.aborted) return; setError(err instanceof Error ? err.message : "Search failed"); 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(() => { if (!open) return; - const timer = setTimeout(() => fetchData(search), search ? 150 : 0); - return () => clearTimeout(timer); + if (!search.trim()) { + 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]); // Reset on close @@ -252,7 +265,7 @@ export function CommandPalette() {

{error}

+
+ + ); + } + + return this.props.children; + } +} diff --git a/src/components/ai-elements/mention-textarea-overlay.tsx b/src/components/ai-elements/mention-textarea-overlay.tsx index b72c330..5b05636 100644 --- a/src/components/ai-elements/mention-textarea-overlay.tsx +++ b/src/components/ai-elements/mention-textarea-overlay.tsx @@ -7,6 +7,8 @@ interface Props { content: string; /** Ref to the underlying textarea for scroll sync. */ textareaRef: React.RefObject; + /** Optional className to match the textarea's padding/font. */ + className?: string; } /** @@ -19,6 +21,7 @@ interface Props { export const MentionTextareaOverlay = memo(function MentionTextareaOverlay({ content, textareaRef, + className = "", }: Props) { const overlayRef = useRef(null); @@ -50,7 +53,7 @@ export const MentionTextareaOverlay = memo(function MentionTextareaOverlay({
{segments.map((seg, i) => { if (seg.type === "mention" && seg.mention) { diff --git a/src/components/repo/repo-view.tsx b/src/components/repo/repo-view.tsx index b1471a0..089e6c9 100644 --- a/src/components/repo/repo-view.tsx +++ b/src/components/repo/repo-view.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { client } from "@/client"; import { @@ -6,6 +6,7 @@ import { GitBranch, FileText, GitCommitHorizontal, Tag, Users, } from "lucide-react"; import { cn } from "@/lib/utils"; +import { sanitizeHtml } from "@/lib/sanitize-html"; import CodePanel from "./code-panel"; import CommitsPanel from "./commits-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 safeReadmeHtml = useMemo( + () => sanitizeHtml(readme?.html ?? ""), + [readme?.html], + ); if (isLoading) { return ( @@ -98,7 +103,7 @@ export default function RepoView({ workspace, repo }: Props) { } const tabs = [ - ...(readme?.html ? [{ id: "readme", label: "README", icon: }] : []), + ...(safeReadmeHtml ? [{ id: "readme", label: "README", icon: }] : []), { id: "code", label: "Code", icon: }, { id: "commits", label: "Commits", icon: }, { id: "branches", label: "Branches", icon: }, @@ -161,8 +166,8 @@ export default function RepoView({ workspace, repo }: Props) { {/* Tab content */}
{activeTab === "code" && } - {activeTab === "readme" && readme?.html && ( -
+ {activeTab === "readme" && safeReadmeHtml && ( +
)} {activeTab === "commits" && } {activeTab === "branches" && } diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index c348bb0..1e1adac 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -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 { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { @@ -14,14 +14,16 @@ import { XIcon, } from "lucide-react"; import { cn } from "@/lib/utils"; -import SettingsProfilePage from "@/page/settings/profile"; -import SettingsSecurityPage from "@/page/settings/security"; -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 SettingsTokensPage from "@/page/settings/tokens"; -import SettingsSshKeysPage from "@/page/settings/ssh-keys"; + +// Lazy-loaded settings sections — avoid bundling all settings into the modal chunk +const SettingsProfilePage = lazy(() => import("@/page/settings/profile")); +const SettingsSecurityPage = lazy(() => import("@/page/settings/security")); +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 SettingsTokensPage = lazy(() => import("@/page/settings/tokens")); +const SettingsSshKeysPage = lazy(() => import("@/page/settings/ssh-keys")); type SectionKey = | "profile" @@ -239,7 +241,9 @@ export function SettingsModal({ open, onClose, initialSection }: SettingsModalPr {/* Scrollable content */}
- + {Array.from({ length: 5 }).map((_, i) =>
)}
}> + +
diff --git a/src/components/shell/personal-sidebar.tsx b/src/components/shell/personal-sidebar.tsx index de78171..65e93b1 100644 --- a/src/components/shell/personal-sidebar.tsx +++ b/src/components/shell/personal-sidebar.tsx @@ -37,7 +37,7 @@ function PersonalAvatar() { )} > {me?.avatar_url ? ( - + {name ) : ( workspaceInitial(name) )} diff --git a/src/components/shell/rail.tsx b/src/components/shell/rail.tsx index ab0a181..876e63c 100644 --- a/src/components/shell/rail.tsx +++ b/src/components/shell/rail.tsx @@ -47,7 +47,7 @@ function WorkspaceIcon({ > {workspace.avatar_url ? ( diff --git a/src/components/shell/workspace-sidebar-inner.tsx b/src/components/shell/workspace-sidebar-inner.tsx index 4e4002b..269db39 100644 --- a/src/components/shell/workspace-sidebar-inner.tsx +++ b/src/components/shell/workspace-sidebar-inner.tsx @@ -14,7 +14,7 @@ export function WorkspaceAvatar({ workspace }: { workspace?: WorkspaceResponse } )} > {workspace?.avatar_url ? ( - + {name ) : ( workspaceInitial(name) )} diff --git a/src/components/shell/workspace-sidebar.tsx b/src/components/shell/workspace-sidebar.tsx index d70e1f8..14ae133 100644 --- a/src/components/shell/workspace-sidebar.tsx +++ b/src/components/shell/workspace-sidebar.tsx @@ -12,15 +12,18 @@ import { Plus, Search, Settings, + UserPlus, Users, X, } from "lucide-react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { api, client } from "@/client"; +import { useAuth } from "@/context/auth-context"; import { Button } from "@/components/ui/button"; import { CommandPalette } from "@/components/CommandPalette"; import RoomCreateDialog from "@/page/workspace/channel/room-create-dialog"; +import InviteDialog from "@/page/workspace/channel/invite-dialog"; import NavShell from "./rail"; import { ShellNavLink } from "./shared"; import { cn } from "@/lib/utils"; @@ -386,6 +389,7 @@ function TopBar() { const { data: channelsData } = useQuery<{ rooms: ChannelItem[]; categories: CategoryItem[]; + workspace_id?: string; }>({ queryKey: ["channel", "rooms"], queryFn: async () => { @@ -399,6 +403,8 @@ function TopBar() { const channelName = roomId ? channelsData?.rooms?.find((r) => r.id === roomId)?.name : null; + const workspaceId = + channelsData?.rooms?.[0]?.workspace_id ?? channelsData?.workspace_id ?? ""; const title = isRepo ? segments[2] @@ -458,6 +464,18 @@ function TopBar() { > + {workspaceId && ( + + + + )} + +
+ ); +} + +function apiErrorStatus(error: unknown) { + return (error as { response?: { status?: number } } | null)?.response?.status; +} + /* ------------------------------------------------------------------ */ /* Shell */ /* ------------------------------------------------------------------ */ export function WorkspaceShell() { + const { projectName = "" } = useParams(); + const location = useLocation(); const [searchParams] = useSearchParams(); const isEmbed = searchParams.get("embed") === "1"; const [showMembers, setShowMembers] = 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<{ rooms: ChannelItem[]; @@ -571,6 +647,7 @@ export function WorkspaceShell() { const response = await api.get("/api/v1/ws/rooms"); return response.data; }, + enabled: hasWorkspaceAccess, retry: false, }); const workspaceId = @@ -588,7 +665,7 @@ export function WorkspaceShell() { ); return res.data; }, - enabled: Boolean(workspaceId), + enabled: hasWorkspaceAccess && Boolean(workspaceId), retry: false, staleTime: 30000, }); @@ -607,6 +684,49 @@ export function WorkspaceShell() { [showMembers, showSearch, members, membersLoading], ); + if (authLoading) { + return ; + } + + if (!isAuthenticated) { + return ( + + ); + } + + if (workspaceLoading) { + return ; + } + + if (apiErrorStatus(workspaceError) === 403) { + return ( + + + + ); + } + + if (workspaceError) { + return ( + + ); + } + // Embed mode: render only the page content, no shell chrome if (isEmbed) { return ( diff --git a/src/context/auth-context.tsx b/src/context/auth-context.tsx index cd7b706..93a9188 100644 --- a/src/context/auth-context.tsx +++ b/src/context/auth-context.tsx @@ -1,12 +1,15 @@ import { createContext, useContext, + useEffect, + useRef, type ReactNode, } from "react"; import axios from "axios"; import { useQuery } from "@tanstack/react-query"; import { client, type ContextMe } from "@/client"; +import { setFaroUser, pushFaroEvent } from "@/faro"; type AuthContextValue = { me: ContextMe | null; @@ -36,10 +39,45 @@ export function AuthProvider({ children }: { children: ReactNode }) { const { data, error, isLoading, refetch } = useQuery({ queryKey: ["auth", "me"], queryFn: fetchMe, + staleTime: 5 * 60 * 1000, // 5 min — avoid re-fetch on every mount retry: false, }); const me = data ?? null; + const prevMe = useRef(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 ( , +) { + faro.api.pushEvent(name, attributes); +} + +/** + * Push a custom measurement to Faro. + */ +export function pushFaroMeasurement( + type: string, + values: Record, +) { + faro.api.pushMeasurement({ type, values }); +} + +export { faro }; diff --git a/src/hooks/use-faro-measure.ts b/src/hooks/use-faro-measure.ts new file mode 100644 index 0000000..c98ce06 --- /dev/null +++ b/src/hooks/use-faro-measure.ts @@ -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(0); + + useEffect(() => { + startRef.current = performance.now(); + }, []); + + return useCallback( + (values: Record) => { + const elapsed = performance.now() - startRef.current; + pushFaroMeasurement(type, { ...values, elapsedMs: Math.round(elapsed) }); + }, + [type], + ); +} diff --git a/src/lib/ir/mention-chip.tsx b/src/lib/ir/mention-chip.tsx index 1821215..2773a3a 100644 --- a/src/lib/ir/mention-chip.tsx +++ b/src/lib/ir/mention-chip.tsx @@ -1,5 +1,5 @@ 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 type { MentionData } from './parser'; @@ -9,6 +9,7 @@ const entityIcons: Record> = issue: Bug, pr: GitBranch, room: Hash, + all: Megaphone, }; const entityColors: Record = { @@ -17,6 +18,7 @@ const entityColors: Record = { 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', 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 { diff --git a/src/lib/sanitize-html.ts b/src/lib/sanitize-html.ts new file mode 100644 index 0000000..b7233b9 --- /dev/null +++ b/src/lib/sanitize-html.ts @@ -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; +} diff --git a/src/main.tsx b/src/main.tsx index 3cd5525..2394eb8 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' +import './faro' import App from './App.tsx' import {QueryClient, QueryClientProvider} from "@tanstack/react-query"; import { AuthProvider } from "@/context/auth-context"; diff --git a/src/page/auth/login.tsx b/src/page/auth/login.tsx index 049c29d..e73890d 100644 --- a/src/page/auth/login.tsx +++ b/src/page/auth/login.tsx @@ -1,5 +1,5 @@ import { useState, type FormEvent } from "react"; -import { Link, useNavigate } from "react-router"; +import { Link, useNavigate, useSearchParams } from "react-router"; import axios from "axios"; import { client } from "@/client"; @@ -31,6 +31,11 @@ type LoginPayload = { captcha: string; }; +function safeRedirect(value: string | null) { + if (!value || !value.startsWith("/") || value.startsWith("//")) return "/"; + return value; +} + function isTwoFactorRequired(error: unknown) { return ( axios.isAxiosError(error) && @@ -41,6 +46,7 @@ function isTwoFactorRequired(error: unknown) { export default function LoginPage() { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const { refresh } = useAuth(); const [submitting, setSubmitting] = useState(false); const [twoFactorSubmitting, setTwoFactorSubmitting] = useState(false); @@ -56,7 +62,7 @@ export default function LoginPage() { totp_code: totpCode || undefined, }); await refresh(); - navigate("/"); + navigate(safeRedirect(searchParams.get("redirect"))); }; const handleSubmit = async (event: FormEvent) => { diff --git a/src/page/join-invite.tsx b/src/page/join-invite.tsx new file mode 100644 index 0000000..53d44b0 --- /dev/null +++ b/src/page/join-invite.tsx @@ -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 ( +
+
+

{title}

+

{description}

+ {actionHref && actionText && ( + + )} +
+
+ ); +} + +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(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("/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 ; + + if (authLoading) { + return ; + } + + if (!isAuthenticated) { + return ( + + ); + } + + return ( +
+
+
+
+ +
+
+

Join workspace

+

Accept this invitation to continue.

+
+
+ + {targetPath ? ( +
+
+ + Invite accepted. You can now open the workspace. +
+ +
+ ) : ( +
+ {error && ( +
+ + {error} +
+ )} + + +
+ )} +
+
+ ); +} diff --git a/src/page/landing/home.tsx b/src/page/landing/home.tsx index c60ff06..60f964d 100644 --- a/src/page/landing/home.tsx +++ b/src/page/landing/home.tsx @@ -6,6 +6,12 @@ import TerminalDemo from "@/components/landing/terminal-demo"; import DashboardMockup from "@/components/landing/dashboard-mockup"; 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() { return ( <> @@ -55,11 +61,7 @@ export default function LandingHome() { {/* Product loop */}
- {[ - { 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) => ( + {productHighlights.map((item) => (
{item.label}
{item.value}
diff --git a/src/page/me/chat-conversation.tsx b/src/page/me/chat-conversation.tsx index 8fc9bba..a33b66e 100644 --- a/src/page/me/chat-conversation.tsx +++ b/src/page/me/chat-conversation.tsx @@ -132,8 +132,8 @@ export default function MeChatConversationPage() { behavior: "instant", }); }); - } catch { - // Non-critical. + } catch (err) { + console.error("Failed to load conversation:", err); } }, [conversationId]); @@ -552,29 +552,32 @@ function ChatComposer({ onModelChange: (provider: string) => void; }) { const { textInput } = usePromptInputController(); + const textInputRef = useRef(textInput); + textInputRef.current = textInput; const handleSubmit = useCallback( (text: string) => { if (text.trim()) { sendMessage(text); - textInput.clear(); + textInputRef.current.clear(); } }, - [sendMessage, textInput], + [sendMessage], ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey && !sending) { e.preventDefault(); - const text = textInput.value; + const ti = textInputRef.current; + const text = ti.value; if (text.trim()) { sendMessage(text); - textInput.clear(); + ti.clear(); } } }, - [sendMessage, textInput, sending], + [sendMessage, sending], ); return ( diff --git a/src/page/me/following.tsx b/src/page/me/following.tsx index c24cbc3..688389a 100644 --- a/src/page/me/following.tsx +++ b/src/page/me/following.tsx @@ -83,7 +83,7 @@ export default function MeFollowingPage() {
- {user.avatar_url ? : workspaceInitial(user.username)} + {user.avatar_url ? {user.username : workspaceInitial(user.username)}

{user.display_name ?? user.username}

diff --git a/src/page/me/overview/activity.tsx b/src/page/me/overview/activity.tsx index e12c885..e4fbb64 100644 --- a/src/page/me/overview/activity.tsx +++ b/src/page/me/overview/activity.tsx @@ -28,7 +28,7 @@ export function WorkspaceList({ workspaces }: { workspaces: { name: string; avat - {ws.avatar_url ? : workspaceInitial(ws.name)} + {ws.avatar_url ? {ws.name : workspaceInitial(ws.name)}
diff --git a/src/page/me/overview/index.tsx b/src/page/me/overview/index.tsx index d1b0cbb..b4dbb36 100644 --- a/src/page/me/overview/index.tsx +++ b/src/page/me/overview/index.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { useAuth } from "@/context/auth-context"; import { useQuery } from "@tanstack/react-query"; import { client } from "@/client"; @@ -49,13 +50,20 @@ export default function MeOverviewPage() { ? 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 (
{/* Profile header */}
{avatarUrl ? ( - + {displayName ) : ( workspaceInitial(displayName) )} @@ -86,12 +94,7 @@ export default function MeOverviewPage() { {/* Stats */}
- {[ - { 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) => ( + {stats.map((stat) => ( - {ws.avatar_url ? : workspaceInitial(ws.name)} + {ws.avatar_url ? {ws.name : workspaceInitial(ws.name)}
diff --git a/src/page/settings/security.tsx b/src/page/settings/security.tsx index d90193b..23a6af4 100644 --- a/src/page/settings/security.tsx +++ b/src/page/settings/security.tsx @@ -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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -7,6 +8,10 @@ import { Label } from "@/components/ui/label"; import { Shield, ShieldOff, Mail } from "lucide-react"; function TwoFactorSection() { + const queryClient = useQueryClient(); + const [verificationCode, setVerificationCode] = useState(""); + const [verifiedBackupCodes, setVerifiedBackupCodes] = useState([]); + const { data: status, isLoading } = useQuery({ queryKey: ["auth", "2fa", "status"], 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) { return
; } @@ -37,11 +53,23 @@ function TwoFactorSection() { Your account is protected with 2FA - +

Method: {status.method ?? "TOTP"} {status.has_backup_codes ? " — Backup codes available" : " — No backup codes"}

+ {verifiedBackupCodes.length > 0 && ( +
+

+ Backup codes — save these now. They won't be shown again. +

+
+ {verifiedBackupCodes.map((code) => ( +

{code}

+ ))} +
+
+ )}
); @@ -58,21 +86,37 @@ function TwoFactorSection() { {enable2fa.data ? ( -
-

+

{ + e.preventDefault(); + verify2fa.mutate(verificationCode.trim()); + }} + > +

Scan this QR code with your authenticator app, then enter the code below to verify.

2FA QR code

Secret: {enable2fa.data.secret}

-

Backup codes (save these!)

-
- {enable2fa.data.backup_codes.map((code) => ( -

{code}

- ))} -
+ + setVerificationCode(e.target.value)} + placeholder="123456" + value={verificationCode} + />
-
+ {verify2fa.isError && ( +

Invalid code. Please try again.

+ )} + + ) : (