Przeglądaj źródła

fix(session-details): address review feedback (sse perf, matchMedia fallback, parse logging)

- Add prev/next request navigation with adjacent sequence query
- Replace sequence number with precise timestamp in request sidebar
- Refactor CodeDisplay theme detection to use MutationObserver on documentElement class
- Add expandedMaxHeight and defaultExpanded props for better content control
- Optimize SSE view with virtual scrolling and row expansion tracking
- Update i18n messages for navigation buttons across 5 locales

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
ding113 1 miesiąc temu
rodzic
commit
4337328f2b

+ 3 - 1
messages/en/dashboard.json

@@ -379,7 +379,9 @@
       "responseBody": "Response Body",
       "responseBodyDescription": "Complete server response (5-minute TTL)",
       "noHeaders": "No data",
-      "noData": "No Data"
+      "noData": "No Data",
+      "prevRequest": "Previous",
+      "nextRequest": "Next"
     },
     "actions": {
       "back": "Back",

+ 3 - 1
messages/ja/dashboard.json

@@ -378,7 +378,9 @@
       "responseBody": "レスポンスボディ",
       "responseBodyDescription": "サーバーからの完全なレスポンス (5 分間の TTL)",
       "noHeaders": "データなし",
-      "noData": "データなし"
+      "noData": "データなし",
+      "prevRequest": "前のリクエスト",
+      "nextRequest": "次のリクエスト"
     },
     "actions": {
       "back": "戻る",

+ 3 - 1
messages/ru/dashboard.json

@@ -378,7 +378,9 @@
       "responseBody": "Тело ответа",
       "responseBodyDescription": "Полный ответ сервера (TTL 5 минут)",
       "noHeaders": "Нет данных",
-      "noData": "Нет данных"
+      "noData": "Нет данных",
+      "prevRequest": "Предыдущий",
+      "nextRequest": "Следующий"
     },
     "actions": {
       "back": "Назад",

+ 3 - 1
messages/zh-CN/dashboard.json

@@ -379,7 +379,9 @@
       "responseBody": "响应体",
       "responseBodyDescription": "服务器返回的完整响应(5分钟 TTL)",
       "noHeaders": "无数据",
-      "noData": "暂无数据"
+      "noData": "暂无数据",
+      "prevRequest": "上一条",
+      "nextRequest": "下一条"
     },
     "actions": {
       "back": "返回",

+ 3 - 1
messages/zh-TW/dashboard.json

@@ -379,7 +379,9 @@
       "responseBody": "響應體",
       "responseBodyDescription": "伺服器傳回的完整回覆(5分鐘 TTL)",
       "noHeaders": "無資料",
-      "noData": "暫無資料"
+      "noData": "暫無資料",
+      "prevRequest": "上一條",
+      "nextRequest": "下一條"
     },
     "actions": {
       "back": "返回",

+ 10 - 0
src/actions/active-sessions.ts

@@ -519,6 +519,8 @@ export async function getSessionDetails(
       ReturnType<typeof import("@/repository/message").aggregateSessionStats>
     > | null;
     currentSequence: number | null;
+    prevSequence: number | null;
+    nextSequence: number | null;
   }>
 > {
   try {
@@ -581,6 +583,12 @@ export async function getSessionDetails(
     const normalizedSequence = normalizeRequestSequence(requestSequence);
     const effectiveSequence = normalizedSequence ?? (requestCount > 0 ? requestCount : undefined);
 
+    const { findAdjacentRequestSequences } = await import("@/repository/message");
+    const adjacent =
+      effectiveSequence == null
+        ? { prevSequence: null, nextSequence: null }
+        : await findAdjacentRequestSequences(sessionId, effectiveSequence);
+
     const parseJsonStringOrNull = (value: unknown): unknown => {
       if (typeof value !== "string") return value;
       try {
@@ -615,6 +623,8 @@ export async function getSessionDetails(
         responseHeaders,
         sessionStats,
         currentSequence: effectiveSequence ?? null,
+        prevSequence: adjacent.prevSequence,
+        nextSequence: adjacent.nextSequence,
       },
     };
   } catch (error) {

+ 16 - 12
src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx

@@ -391,10 +391,12 @@ export function VirtualizedLogsTable({
                     <TooltipProvider>
                       <Tooltip delayDuration={250}>
                         <TooltipTrigger asChild>
-                          <span className="cursor-help">
-                            {formatTokenAmount(log.inputTokens)} /{" "}
-                            {formatTokenAmount(log.outputTokens)}
-                          </span>
+                          <div className="cursor-help flex flex-col items-end leading-tight tabular-nums">
+                            <span>{formatTokenAmount(log.inputTokens)}</span>
+                            <span className="text-muted-foreground">
+                              {formatTokenAmount(log.outputTokens)}
+                            </span>
+                          </div>
                         </TooltipTrigger>
                         <TooltipContent align="end" className="text-xs space-y-1">
                           <div>
@@ -413,16 +415,18 @@ export function VirtualizedLogsTable({
                     <TooltipProvider>
                       <Tooltip delayDuration={250}>
                         <TooltipTrigger asChild>
-                          <div className="flex items-center justify-end gap-1 cursor-help">
-                            <span>
-                              {formatTokenAmount(log.cacheCreationInputTokens)} /{" "}
+                          <div className="cursor-help flex flex-col items-end leading-tight tabular-nums">
+                            <div className="flex items-center gap-1">
+                              <span>{formatTokenAmount(log.cacheCreationInputTokens)}</span>
+                              {log.cacheTtlApplied ? (
+                                <Badge variant="outline" className="text-[10px] leading-tight px-1">
+                                  {log.cacheTtlApplied}
+                                </Badge>
+                              ) : null}
+                            </div>
+                            <span className="text-muted-foreground">
                               {formatTokenAmount(log.cacheReadInputTokens)}
                             </span>
-                            {log.cacheTtlApplied ? (
-                              <Badge variant="outline" className="text-[10px] leading-tight px-1">
-                                {log.cacheTtlApplied}
-                              </Badge>
-                            ) : null}
                           </div>
                         </TooltipTrigger>
                         <TooltipContent align="end" className="text-xs space-y-1">

+ 31 - 2
src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar.tsx

@@ -101,6 +101,25 @@ export function RequestListSidebar({
     return "<1m";
   };
 
+  const formatRequestTimestamp = (date: Date | null) => {
+    if (!date) return "-";
+    const d = new Date(date);
+    if (Number.isNaN(d.getTime())) return "-";
+
+    const now = new Date();
+    const sameDay =
+      d.getFullYear() === now.getFullYear() &&
+      d.getMonth() === now.getMonth() &&
+      d.getDate() === now.getDate();
+
+    const pad2 = (v: number) => String(v).padStart(2, "0");
+    const pad3 = (v: number) => String(v).padStart(3, "0");
+    const time = `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}.${pad3(d.getMilliseconds())}`;
+
+    if (sameDay) return time;
+    return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${time}`;
+  };
+
   // 获取状态图标
   const getStatusIcon = (statusCode: number | null) => {
     if (!statusCode) {
@@ -204,7 +223,14 @@ export function RequestListSidebar({
                 <div className="flex items-center justify-between">
                   <div className="flex items-center gap-1.5">
                     {getStatusIcon(request.statusCode)}
-                    <span className="text-sm font-medium">#{request.sequence}</span>
+                    <span
+                      className="text-sm font-medium font-mono tabular-nums"
+                      title={
+                        request.createdAt ? new Date(request.createdAt).toISOString() : undefined
+                      }
+                    >
+                      {formatRequestTimestamp(request.createdAt)}
+                    </span>
                   </div>
                   <div className="flex items-center gap-1 text-xs text-muted-foreground">
                     <Clock className="h-3 w-3" />
@@ -213,7 +239,10 @@ export function RequestListSidebar({
                 </div>
                 <div className="mt-1 flex items-center justify-between">
                   <span className="text-xs text-muted-foreground font-mono truncate max-w-[120px]">
-                    {request.model || "-"}
+                    {request.model || "-"}{" "}
+                    <span className="text-[10px] text-muted-foreground/70">
+                      #{request.sequence}
+                    </span>
                   </span>
                   {request.statusCode && (
                     <Badge

+ 9 - 0
src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-details-tabs.tsx

@@ -29,6 +29,7 @@ export function SessionMessagesDetailsTabs({
   responseHeaders,
 }: SessionMessagesDetailsTabsProps) {
   const t = useTranslations("dashboard.sessions");
+  const codeExpandedMaxHeight = "calc(100vh - 260px)";
 
   const requestBodyContent = useMemo(() => {
     if (messages === null) return null;
@@ -66,6 +67,8 @@ export function SessionMessagesDetailsTabs({
             language="text"
             fileName="request.headers"
             maxHeight="600px"
+            defaultExpanded
+            expandedMaxHeight={codeExpandedMaxHeight}
           />
         )}
       </TabsContent>
@@ -79,6 +82,8 @@ export function SessionMessagesDetailsTabs({
             language="json"
             fileName="request.json"
             maxHeight="600px"
+            defaultExpanded
+            expandedMaxHeight={codeExpandedMaxHeight}
           />
         )}
       </TabsContent>
@@ -92,6 +97,8 @@ export function SessionMessagesDetailsTabs({
             language="text"
             fileName="response.headers"
             maxHeight="600px"
+            defaultExpanded
+            expandedMaxHeight={codeExpandedMaxHeight}
           />
         )}
       </TabsContent>
@@ -105,6 +112,8 @@ export function SessionMessagesDetailsTabs({
             language={responseLanguage}
             fileName={responseLanguage === "sse" ? "response.sse" : "response.json"}
             maxHeight="600px"
+            defaultExpanded
+            expandedMaxHeight={codeExpandedMaxHeight}
           />
         )}
       </TabsContent>

+ 23 - 0
src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx

@@ -64,6 +64,8 @@ export function SessionMessagesClient() {
       Extract<Awaited<ReturnType<typeof getSessionDetails>>, { ok: true }>["data"]["sessionStats"]
     >(null);
   const [currentSequence, setCurrentSequence] = useState<number | null>(null);
+  const [prevSequence, setPrevSequence] = useState<number | null>(null);
+  const [nextSequence, setNextSequence] = useState<number | null>(null);
   const [isLoading, setIsLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
   const [copiedMessages, setCopiedMessages] = useState(false);
@@ -109,6 +111,8 @@ export function SessionMessagesClient() {
           setResponseHeaders(result.data.responseHeaders);
           setSessionStats(result.data.sessionStats);
           setCurrentSequence(result.data.currentSequence);
+          setPrevSequence(result.data.prevSequence);
+          setNextSequence(result.data.nextSequence);
         } else {
           setError(result.error || t("status.fetchFailed"));
         }
@@ -325,6 +329,25 @@ export function SessionMessagesClient() {
                     requestHeaders={requestHeaders}
                     responseHeaders={responseHeaders}
                   />
+
+                  <div className="flex items-center justify-between">
+                    <Button
+                      variant="outline"
+                      size="sm"
+                      disabled={!prevSequence}
+                      onClick={() => prevSequence && handleSelectRequest(prevSequence)}
+                    >
+                      {t("details.prevRequest")}
+                    </Button>
+                    <Button
+                      variant="outline"
+                      size="sm"
+                      disabled={!nextSequence}
+                      onClick={() => nextSequence && handleSelectRequest(nextSequence)}
+                    >
+                      {t("details.nextRequest")}
+                    </Button>
+                  </div>
                 </div>
 
                 {/* 无数据提示 */}

+ 7 - 2
src/app/[locale]/my-usage/_components/usage-logs-table.tsx

@@ -95,8 +95,13 @@ export function UsageLogsTable({
                       </div>
                     ) : null}
                   </TableCell>
-                  <TableCell className="text-right text-sm font-mono">
-                    {log.inputTokens}/{log.outputTokens}
+                  <TableCell className="text-right text-xs font-mono tabular-nums">
+                    <div className="flex flex-col items-end leading-tight">
+                      <span>{formatTokenAmount(log.inputTokens)}</span>
+                      <span className="text-muted-foreground">
+                        {formatTokenAmount(log.outputTokens)}
+                      </span>
+                    </div>
                   </TableCell>
                   <TableCell className="text-right font-mono text-xs">
                     <TooltipProvider>

+ 12 - 63
src/components/ui/__tests__/code-display.test.tsx

@@ -6,7 +6,7 @@ import type { ReactNode } from "react";
 import { act } from "react";
 import { createRoot } from "react-dom/client";
 import { NextIntlClientProvider } from "next-intl";
-import { describe, expect, test, vi } from "vitest";
+import { describe, expect, test } from "vitest";
 import { CodeDisplay } from "@/components/ui/code-display";
 
 const messages = {
@@ -228,19 +228,7 @@ describe("CodeDisplay", () => {
       <CodeDisplay content={sse} language="sse" fileName="response.sse" />
     );
 
-    expect(container.querySelectorAll("[data-testid='code-display-sse-row']").length).toBe(10);
-
-    const next = container.querySelector(
-      "[data-testid='code-display-page-next']"
-    ) as HTMLButtonElement;
-    click(next);
-    expect(container.querySelectorAll("[data-testid='code-display-sse-row']").length).toBe(1);
-
-    const prev = container.querySelector(
-      "[data-testid='code-display-page-prev']"
-    ) as HTMLButtonElement;
-    click(prev);
-    expect(container.querySelectorAll("[data-testid='code-display-sse-row']").length).toBe(10);
+    expect(container.querySelectorAll("[data-testid='code-display-sse-row']").length).toBe(11);
 
     const input = container.querySelector(
       "[data-testid='code-display-search']"
@@ -255,36 +243,22 @@ describe("CodeDisplay", () => {
     unmount();
   });
 
-  test("auto theme uses matchMedia when available", async () => {
-    const original = window.matchMedia;
-    const add = vi.fn();
-    const remove = vi.fn();
+  test("follows global theme class on <html>", async () => {
+    document.documentElement.classList.remove("dark");
 
-    // @ts-expect-error test stub
-    window.matchMedia = () => ({
-      matches: true,
-      addEventListener: add,
-      removeEventListener: remove,
-    });
+    const { container, unmount } = renderWithIntl(
+      <CodeDisplay content='{"a":1}' language="json" />
+    );
+    const root = container.querySelector("[data-testid='code-display']") as HTMLElement;
+    expect(root.getAttribute("data-resolved-theme")).toBe("light");
 
-    const { unmount } = renderWithIntl(<CodeDisplay content='{"a":1}' language="json" />);
+    document.documentElement.classList.add("dark");
     await act(async () => {
-      await Promise.resolve();
+      await new Promise((r) => setTimeout(r, 0));
     });
-    expect(add).toHaveBeenCalled();
-
-    unmount();
-    window.matchMedia = original;
-  });
-
-  test("auto theme is a no-op when matchMedia is missing", () => {
-    const original = window.matchMedia;
-    // @ts-expect-error test stub
-    window.matchMedia = undefined;
+    expect(root.getAttribute("data-resolved-theme")).toBe("dark");
 
-    const { unmount } = renderWithIntl(<CodeDisplay content='{"a":1}' language="json" />);
     unmount();
-    window.matchMedia = original;
   });
 
   test("large content shows expand/collapse and toggles expanded state", () => {
@@ -329,29 +303,4 @@ describe("CodeDisplay", () => {
     expect(container.textContent).toContain("10,000 lines");
     unmount();
   });
-
-  test("theme toggles update data-theme", () => {
-    const { container, unmount } = renderWithIntl(
-      <CodeDisplay content='{"a":1}' language="json" fileName="request.json" />
-    );
-
-    const root = container.querySelector("[data-testid='code-display']") as HTMLElement;
-    expect(root.getAttribute("data-theme")).toBe("auto");
-
-    const dark = container.querySelector("[data-testid='code-display-theme-dark']") as HTMLElement;
-    click(dark);
-    expect(root.getAttribute("data-theme")).toBe("dark");
-
-    const light = container.querySelector(
-      "[data-testid='code-display-theme-light']"
-    ) as HTMLElement;
-    click(light);
-    expect(root.getAttribute("data-theme")).toBe("light");
-
-    const auto = container.querySelector("[data-testid='code-display-theme-auto']") as HTMLElement;
-    click(auto);
-    expect(root.getAttribute("data-theme")).toBe("auto");
-
-    unmount();
-  });
 });

+ 194 - 200
src/components/ui/code-display.tsx

@@ -1,27 +1,16 @@
 "use client";
 
-import { ChevronDown, ChevronUp, File as FileIcon, Laptop, Moon, Search, Sun } from "lucide-react";
+import { ChevronDown, ChevronUp, File as FileIcon, Search } from "lucide-react";
 import { useTranslations } from "next-intl";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
 import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
 import { oneDark, oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
 import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
-import {
-  Table,
-  TableBody,
-  TableCell,
-  TableHead,
-  TableHeader,
-  TableRow,
-} from "@/components/ui/table";
 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { cn } from "@/lib/utils";
 import { parseSSEDataForDisplay } from "@/lib/utils/sse";
 
-type ThemePreference = "auto" | "light" | "dark";
-
 export type CodeDisplayLanguage = "json" | "sse" | "text";
 
 const MAX_CONTENT_SIZE = 1_000_000; // 1MB
@@ -32,6 +21,8 @@ export interface CodeDisplayProps {
   language: CodeDisplayLanguage;
   fileName?: string;
   maxHeight?: string;
+  expandedMaxHeight?: string;
+  defaultExpanded?: boolean;
 }
 
 function safeJsonParse(text: string): { ok: true; value: unknown } | { ok: false } {
@@ -72,6 +63,8 @@ export function CodeDisplay({
   language,
   fileName,
   maxHeight = "600px",
+  expandedMaxHeight,
+  defaultExpanded = false,
 }: CodeDisplayProps) {
   const t = useTranslations("dashboard.sessions");
   const isOverMaxBytes = content.length > MAX_CONTENT_SIZE;
@@ -79,30 +72,24 @@ export function CodeDisplay({
   const [mode, setMode] = useState<"raw" | "pretty">(getDefaultMode(language));
   const [searchQuery, setSearchQuery] = useState("");
   const [showOnlyMatches, setShowOnlyMatches] = useState(false);
-  const [expanded, setExpanded] = useState(false);
-  const [themePreference, setThemePreference] = useState<ThemePreference>("auto");
-  const [page, setPage] = useState(1);
-  const [systemTheme, setSystemTheme] = useState<"light" | "dark">("light");
+  const [expanded, setExpanded] = useState(defaultExpanded);
+  const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("light");
+  const [expandedSseRows, setExpandedSseRows] = useState<Set<number>>(() => new Set());
+  const sseScrollRef = useRef<HTMLDivElement | null>(null);
+  const [sseViewportHeight, setSseViewportHeight] = useState(0);
+  const [sseScrollTop, setSseScrollTop] = useState(0);
 
   useEffect(() => {
-    if (!window.matchMedia) return;
+    const getTheme = () => (document.documentElement.classList.contains("dark") ? "dark" : "light");
 
-    const media = window.matchMedia("(prefers-color-scheme: dark)");
-    const update = () => setSystemTheme(media.matches ? "dark" : "light");
-    update();
+    setResolvedTheme(getTheme());
 
-    if (media.addEventListener) {
-      media.addEventListener("change", update);
-      return () => media.removeEventListener("change", update);
-    }
+    const observer = new MutationObserver(() => setResolvedTheme(getTheme()));
+    observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
 
-    media.addListener(update);
-    return () => media.removeListener(update);
+    return () => observer.disconnect();
   }, []);
 
-  const effectiveTheme: ThemePreference = themePreference;
-  const resolvedEffectiveTheme = effectiveTheme === "auto" ? systemTheme : effectiveTheme;
-
   const lineCount = useMemo(() => {
     if (isOverMaxBytes) return 0;
     return countLinesUpTo(content, MAX_LINES + 1);
@@ -137,6 +124,32 @@ export function CodeDisplay({
     });
   }, [searchQuery, sseEvents]);
 
+  useEffect(() => {
+    if (language !== "sse" || mode !== "pretty") return;
+    setExpandedSseRows(new Set());
+  }, [language, mode]);
+
+  useEffect(() => {
+    if (language !== "sse" || mode !== "pretty") return;
+    const el = sseScrollRef.current;
+    if (!el) return;
+
+    const update = () => setSseViewportHeight(el.clientHeight);
+    update();
+
+    let ro: ResizeObserver | null = null;
+    if (typeof ResizeObserver !== "undefined") {
+      ro = new ResizeObserver(update);
+      ro.observe(el);
+    }
+
+    window.addEventListener("resize", update);
+    return () => {
+      ro?.disconnect();
+      window.removeEventListener("resize", update);
+    };
+  }, [language, mode]);
+
   const lineFilteredText = useMemo(() => {
     if (language === "sse") return null;
     if (isOverMaxBytes) return content;
@@ -147,31 +160,9 @@ export function CodeDisplay({
     return matches.length === 0 ? "" : matches.join("\n");
   }, [content, isOverMaxBytes, language, searchQuery, showOnlyMatches]);
 
-  type SseEvent = ReturnType<typeof parseSSEDataForDisplay>[number];
-  const pagination = useMemo((): {
-    pageSize: number;
-    totalPages: number;
-    page: number;
-    items: SseEvent[];
-  } => {
-    if (!filteredSseEvents) {
-      return { pageSize: 10, totalPages: 1, page: 1, items: [] };
-    }
-    const pageSize = 10;
-    const totalPages = Math.max(1, Math.ceil(filteredSseEvents.length / pageSize));
-    const safePage = Math.min(Math.max(1, page), totalPages);
-    const start = (safePage - 1) * pageSize;
-    const end = start + pageSize;
-    return {
-      pageSize,
-      totalPages,
-      page: safePage,
-      items: filteredSseEvents.slice(start, end),
-    };
-  }, [filteredSseEvents, page]);
-
-  const highlighterStyle = resolvedEffectiveTheme === "dark" ? oneDark : oneLight;
+  const highlighterStyle = resolvedTheme === "dark" ? oneDark : oneLight;
   const displayText = lineFilteredText ?? content;
+  const contentMaxHeight = isExpanded ? expandedMaxHeight : maxHeight;
 
   if (isHardLimited) {
     const sizeBytes = content.length;
@@ -220,8 +211,8 @@ export function CodeDisplay({
           data-testid="code-display-search"
           value={searchQuery}
           onChange={(e) => {
+            setExpandedSseRows(new Set());
             setSearchQuery(e.target.value);
-            setPage(1);
           }}
           placeholder={t("codeDisplay.searchPlaceholder")}
           className="pl-8 h-9"
@@ -240,42 +231,6 @@ export function CodeDisplay({
         </Button>
       )}
 
-      <div className="flex items-center gap-1">
-        <Button
-          type="button"
-          variant="ghost"
-          size="icon"
-          onClick={() => setThemePreference("auto")}
-          data-testid="code-display-theme-auto"
-          aria-label={t("codeDisplay.themeAuto")}
-          className={cn("h-9 w-9", themePreference === "auto" && "bg-accent")}
-        >
-          <Laptop className="h-4 w-4" />
-        </Button>
-        <Button
-          type="button"
-          variant="ghost"
-          size="icon"
-          onClick={() => setThemePreference("light")}
-          data-testid="code-display-theme-light"
-          aria-label={t("codeDisplay.themeLight")}
-          className={cn("h-9 w-9", themePreference === "light" && "bg-accent")}
-        >
-          <Sun className="h-4 w-4" />
-        </Button>
-        <Button
-          type="button"
-          variant="ghost"
-          size="icon"
-          onClick={() => setThemePreference("dark")}
-          data-testid="code-display-theme-dark"
-          aria-label={t("codeDisplay.themeDark")}
-          className={cn("h-9 w-9", themePreference === "dark" && "bg-accent")}
-        >
-          <Moon className="h-4 w-4" />
-        </Button>
-      </div>
-
       {isLargeContent && (
         <Button
           type="button"
@@ -306,8 +261,8 @@ export function CodeDisplay({
       data-testid="code-display"
       data-language={language}
       data-expanded={String(isExpanded)}
-      data-theme={themePreference}
-      className="rounded-md border bg-muted/30"
+      data-resolved-theme={resolvedTheme}
+      className="rounded-md border bg-muted/30 flex flex-col min-h-0"
     >
       <div className="flex flex-col gap-3 p-3 md:flex-row md:items-center md:justify-between">
         <div className="flex items-center gap-2">
@@ -321,10 +276,7 @@ export function CodeDisplay({
         {headerRight}
       </div>
 
-      <div
-        className={cn("border-t p-3", !isExpanded && "overflow-hidden")}
-        style={{ maxHeight: isExpanded ? undefined : maxHeight }}
-      >
+      <div className="border-t p-3 flex flex-col min-h-0">
         <Tabs value={mode} onValueChange={(v) => setMode(v as "raw" | "pretty")} className="w-full">
           <TabsList className="grid w-full grid-cols-2">
             <TabsTrigger value="raw" data-testid="code-display-mode-raw">
@@ -336,120 +288,162 @@ export function CodeDisplay({
           </TabsList>
 
           <TabsContent value="raw" className="mt-3">
-            <pre className="text-xs whitespace-pre-wrap break-words font-mono">{displayText}</pre>
+            <div className="overflow-auto" style={{ maxHeight: contentMaxHeight }}>
+              <pre className="text-xs whitespace-pre-wrap break-words font-mono">{displayText}</pre>
+            </div>
           </TabsContent>
 
           <TabsContent value="pretty" className="mt-3">
             {language === "json" ? (
-              <SyntaxHighlighter
-                language="json"
-                style={highlighterStyle}
-                customStyle={{
-                  margin: 0,
-                  background: "transparent",
-                  fontSize: "12px",
+              <div className="overflow-auto" style={{ maxHeight: contentMaxHeight }}>
+                <SyntaxHighlighter
+                  language="json"
+                  style={highlighterStyle}
+                  customStyle={{
+                    margin: 0,
+                    background: "transparent",
+                    fontSize: "12px",
+                  }}
+                >
+                  {formattedJson}
+                </SyntaxHighlighter>
+              </div>
+            ) : language === "sse" ? (
+              <div
+                ref={sseScrollRef}
+                className="overflow-auto"
+                style={{ maxHeight: contentMaxHeight }}
+                onScroll={(e) => {
+                  const target = e.currentTarget;
+                  setSseScrollTop(target.scrollTop);
                 }}
               >
-                {formattedJson}
-              </SyntaxHighlighter>
-            ) : language === "sse" ? (
-              <div className="space-y-3">
-                <Table>
-                  <TableHeader>
-                    <TableRow>
-                      <TableHead>#</TableHead>
-                      <TableHead>{t("codeDisplay.sseEvent")}</TableHead>
-                      <TableHead>{t("codeDisplay.sseData")}</TableHead>
-                    </TableRow>
-                  </TableHeader>
-                  <TableBody>
-                    {pagination.items.map((evt, idx) => {
-                      const rowIndex = (pagination.page - 1) * pagination.pageSize + idx + 1;
-                      const dataText =
-                        typeof evt.data === "string" ? evt.data : stringifyPretty(evt.data);
-
-                      return (
-                        <TableRow
-                          key={`${rowIndex}-${evt.event}`}
-                          data-testid="code-display-sse-row"
-                        >
-                          <TableCell className="font-mono text-xs text-muted-foreground">
-                            {rowIndex}
-                          </TableCell>
-                          <TableCell className="font-mono text-xs">{evt.event}</TableCell>
-                          <TableCell className="whitespace-normal">
-                            <details>
-                              <summary className="cursor-pointer select-none text-xs text-muted-foreground">
-                                {dataText.length > 120 ? `${dataText.slice(0, 120)}...` : dataText}
+                {(() => {
+                  if (!filteredSseEvents) return null;
+
+                  if (filteredSseEvents.length === 0) {
+                    return (
+                      <div className="text-xs text-muted-foreground">
+                        {t("codeDisplay.noMatches")}
+                      </div>
+                    );
+                  }
+
+                  const useVirtual =
+                    filteredSseEvents.length > 200 &&
+                    expandedSseRows.size === 0 &&
+                    sseViewportHeight > 0;
+
+                  const estimatedRowHeight = 44;
+                  const overscan = 12;
+                  const total = filteredSseEvents.length;
+
+                  const startIndex = useVirtual
+                    ? Math.max(0, Math.floor(sseScrollTop / estimatedRowHeight) - overscan)
+                    : 0;
+                  const endIndex = useVirtual
+                    ? Math.min(
+                        total,
+                        Math.ceil((sseScrollTop + sseViewportHeight) / estimatedRowHeight) +
+                          overscan
+                      )
+                    : total;
+
+                  const topPad = useVirtual ? startIndex * estimatedRowHeight : 0;
+                  const bottomPad = useVirtual ? (total - endIndex) * estimatedRowHeight : 0;
+
+                  const rows = filteredSseEvents.slice(startIndex, endIndex);
+
+                  return (
+                    <div className="space-y-2">
+                      {topPad > 0 && <div style={{ height: topPad }} />}
+                      {rows.map((evt, localIdx) => {
+                        const index = startIndex + localIdx;
+                        const open = expandedSseRows.has(index);
+                        const dataText =
+                          typeof evt.data === "string" ? evt.data : stringifyPretty(evt.data);
+                        const preview =
+                          dataText.length > 120 ? `${dataText.slice(0, 120)}...` : dataText;
+
+                        return (
+                          <div
+                            key={`${index}-${evt.event}`}
+                            data-testid="code-display-sse-row"
+                            className="rounded-md border bg-background/50"
+                          >
+                            <details open={open}>
+                              <summary
+                                className="cursor-pointer select-none px-3 py-2"
+                                onClick={(e) => {
+                                  e.preventDefault();
+                                  setExpandedSseRows((prev) => {
+                                    const next = new Set(prev);
+                                    if (next.has(index)) {
+                                      next.delete(index);
+                                    } else {
+                                      next.add(index);
+                                    }
+                                    return next;
+                                  });
+                                }}
+                              >
+                                <div className="flex items-start gap-3 min-w-0">
+                                  <span className="w-10 shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
+                                    {index + 1}
+                                  </span>
+                                  <span className="shrink-0 font-mono text-xs">{evt.event}</span>
+                                  <span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
+                                    {preview}
+                                  </span>
+                                </div>
                               </summary>
-                              <div className="mt-2">
-                                <SyntaxHighlighter
-                                  language="json"
-                                  style={highlighterStyle}
-                                  customStyle={{
-                                    margin: 0,
-                                    background: "transparent",
-                                    fontSize: "12px",
-                                  }}
-                                >
-                                  {dataText}
-                                </SyntaxHighlighter>
+                              <div className="px-3 pb-3 pt-2 space-y-2">
+                                <div className="space-y-1">
+                                  <div className="text-xs text-muted-foreground">
+                                    {t("codeDisplay.sseEvent")}
+                                  </div>
+                                  <div className="font-mono text-xs break-all">{evt.event}</div>
+                                </div>
+                                <div className="space-y-1">
+                                  <div className="text-xs text-muted-foreground">
+                                    {t("codeDisplay.sseData")}
+                                  </div>
+                                  <SyntaxHighlighter
+                                    language="json"
+                                    style={highlighterStyle}
+                                    customStyle={{
+                                      margin: 0,
+                                      background: "transparent",
+                                      fontSize: "12px",
+                                    }}
+                                  >
+                                    {dataText}
+                                  </SyntaxHighlighter>
+                                </div>
                               </div>
                             </details>
-                          </TableCell>
-                        </TableRow>
-                      );
-                    })}
-                  </TableBody>
-                </Table>
-
-                <div className="flex items-center justify-between">
-                  <div className="text-xs text-muted-foreground">
-                    {t("codeDisplay.pageInfo", {
-                      page: pagination.page,
-                      total: pagination.totalPages,
-                    })}
-                  </div>
-                  <div className="flex gap-2">
-                    <Button
-                      type="button"
-                      variant="outline"
-                      size="sm"
-                      data-testid="code-display-page-prev"
-                      onClick={() => setPage((p) => Math.max(1, p - 1))}
-                      disabled={pagination.page <= 1}
-                    >
-                      {t("codeDisplay.prevPage")}
-                    </Button>
-                    <Button
-                      type="button"
-                      variant="outline"
-                      size="sm"
-                      data-testid="code-display-page-next"
-                      onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
-                      disabled={pagination.page >= pagination.totalPages}
-                    >
-                      {t("codeDisplay.nextPage")}
-                    </Button>
-                  </div>
-                </div>
-
-                {filteredSseEvents && filteredSseEvents.length === 0 && (
-                  <div className="text-xs text-muted-foreground">{t("codeDisplay.noMatches")}</div>
-                )}
+                          </div>
+                        );
+                      })}
+                      {bottomPad > 0 && <div style={{ height: bottomPad }} />}
+                    </div>
+                  );
+                })()}
               </div>
             ) : (
-              <SyntaxHighlighter
-                language="text"
-                style={highlighterStyle}
-                customStyle={{
-                  margin: 0,
-                  background: "transparent",
-                  fontSize: "12px",
-                }}
-              >
-                {displayText}
-              </SyntaxHighlighter>
+              <div className="overflow-auto" style={{ maxHeight: contentMaxHeight }}>
+                <SyntaxHighlighter
+                  language="text"
+                  style={highlighterStyle}
+                  customStyle={{
+                    margin: 0,
+                    background: "transparent",
+                    fontSize: "12px",
+                  }}
+                >
+                  {displayText}
+                </SyntaxHighlighter>
+              </div>
             )}
           </TabsContent>
         </Tabs>

+ 37 - 1
src/repository/message.ts

@@ -1,6 +1,6 @@
 "use server";
 
-import { and, asc, desc, eq, inArray, isNull, sql } from "drizzle-orm";
+import { and, asc, desc, eq, gt, inArray, isNull, lt, sql } from "drizzle-orm";
 import { db } from "@/drizzle/db";
 import { keys as keysTable, messageRequest, providers, users } from "@/drizzle/schema";
 import { formatCostForStorage } from "@/lib/utils/currency";
@@ -732,3 +732,39 @@ export async function findRequestsBySessionId(
     total,
   };
 }
+
+export async function findAdjacentRequestSequences(
+  sessionId: string,
+  sequence: number
+): Promise<{ prevSequence: number | null; nextSequence: number | null }> {
+  const [prev] = await db
+    .select({
+      sequence: sql<number | null>`max(${messageRequest.requestSequence})`,
+    })
+    .from(messageRequest)
+    .where(
+      and(
+        eq(messageRequest.sessionId, sessionId),
+        isNull(messageRequest.deletedAt),
+        lt(messageRequest.requestSequence, sequence)
+      )
+    );
+
+  const [next] = await db
+    .select({
+      sequence: sql<number | null>`min(${messageRequest.requestSequence})`,
+    })
+    .from(messageRequest)
+    .where(
+      and(
+        eq(messageRequest.sessionId, sessionId),
+        isNull(messageRequest.deletedAt),
+        gt(messageRequest.requestSequence, sequence)
+      )
+    );
+
+  return {
+    prevSequence: prev?.sequence ?? null,
+    nextSequence: next?.sequence ?? null,
+  };
+}