authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-17 10:39:44-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-18 21:59:17-08:00
log582615971ef76e55f994e2fc0ccd9171277551c2
treec6c9ee405de1041d998020792c7436646dd835b8
parentd2e6cfe10929eea55cbc70d6ffad8f7077b4d858

Add per-turn diff viewer with file targeting

- Replace placeholder diff sidebar with real turn/file patch rendering - Link assistant messages to changed-file summaries and open targeted diffs - Persist diff selection state in the store and cover parsing/reducer behavior with tests - Lazy-load `DiffPanel` to reduce initial bundle cost

9 files changed, 680 insertions(+), 43 deletions(-)

apps/web/package.json+1
...@@ -12,6 +12,7 @@...@@ -12,6 +12,7 @@
12 },12 },
13 "dependencies": {13 "dependencies": {
14 "@base-ui/react": "^1.2.0",14 "@base-ui/react": "^1.2.0",
15 "@pierre/diffs": "^1.0.11",
15 "@t3tools/contracts": "workspace:*",16 "@t3tools/contracts": "workspace:*",
16 "@tanstack/react-pacer": "^0.19.4",17 "@tanstack/react-pacer": "^0.19.4",
17 "@tanstack/react-query": "^5.90.0",18 "@tanstack/react-query": "^5.90.0",
apps/web/src/App.tsx+14-3
...@@ -1,8 +1,7 @@...@@ -1,8 +1,7 @@
1import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";1import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";
2import { useEffect, useRef } from "react";2import { Suspense, lazy, useEffect, useRef } from "react";
33
4import ChatView from "./components/ChatView";4import ChatView from "./components/ChatView";
5import DiffPanel from "./components/DiffPanel";
6import Sidebar from "./components/Sidebar";5import Sidebar from "./components/Sidebar";
7import { isElectron } from "./env";6import { isElectron } from "./env";
8import { DEFAULT_MODEL } from "./model-logic";7import { DEFAULT_MODEL } from "./model-logic";
...@@ -13,6 +12,8 @@ import { useNativeApi } from "./hooks/useNativeApi";...@@ -13,6 +12,8 @@ import { useNativeApi } from "./hooks/useNativeApi";
13import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast";12import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast";
14import { invalidateGitQueries } from "./lib/gitReactQuery";13import { invalidateGitQueries } from "./lib/gitReactQuery";
1514
15const DiffPanel = lazy(() => import("./components/DiffPanel"));
16
16function EventRouter() {17function EventRouter() {
17 const api = useNativeApi();18 const api = useNativeApi();
18 const { dispatch } = useStore();19 const { dispatch } = useStore();
...@@ -195,7 +196,17 @@ function Layout() {...@@ -195,7 +196,17 @@ function Layout() {
195 <DesktopProjectBootstrap />196 <DesktopProjectBootstrap />
196 <Sidebar />197 <Sidebar />
197 <ChatView />198 <ChatView />
198 {state.diffOpen && <DiffPanel />}199 {state.diffOpen && (
200 <Suspense
201 fallback={
202 <aside className="flex h-full w-[560px] shrink-0 items-center justify-center border-l border-border bg-card px-4 text-center text-xs text-muted-foreground/70">
203 Loading diff viewer...
204 </aside>
205 }
206 >
207 <DiffPanel />
208 </Suspense>
209 )}
199 </div>210 </div>
200 );211 );
201}212}
apps/web/src/components/ChatView.tsx+83-2
...@@ -52,7 +52,9 @@ import {...@@ -52,7 +52,9 @@ import {
52import {52import {
53 derivePendingApprovals,53 derivePendingApprovals,
54 derivePhase,54 derivePhase,
55 deriveTurnDiffSummaries,
55 deriveTimelineEntries,56 deriveTimelineEntries,
57 type TurnDiffSummary,
56 type PendingApproval,58 type PendingApproval,
57 deriveWorkLogEntries,59 deriveWorkLogEntries,
58 formatDuration,60 formatDuration,
...@@ -406,6 +408,18 @@ export default function ChatView() {...@@ -406,6 +408,18 @@ export default function ChatView() {
406 () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries),408 () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries),
407 [activeThread?.messages, workLogEntries],409 [activeThread?.messages, workLogEntries],
408 );410 );
411 const turnDiffSummaries = useMemo(
412 () => deriveTurnDiffSummaries(activeThread?.events ?? []),
413 [activeThread?.events],
414 );
415 const turnDiffSummaryByAssistantMessageId = useMemo(() => {
416 const byMessageId = new Map<string, TurnDiffSummary>();
417 for (const summary of turnDiffSummaries) {
418 if (!summary.assistantMessageId) continue;
419 byMessageId.set(summary.assistantMessageId, summary);
420 }
421 return byMessageId;
422 }, [turnDiffSummaries]);
409 const completionSummary = useMemo(() => {423 const completionSummary = useMemo(() => {
410 if (!activeThread?.latestTurnStartedAt) return null;424 if (!activeThread?.latestTurnStartedAt) return null;
411 if (!activeThread.latestTurnCompletedAt) return null;425 if (!activeThread.latestTurnCompletedAt) return null;
...@@ -1685,8 +1699,36 @@ export default function ChatView() {...@@ -1685,8 +1699,36 @@ export default function ChatView() {
1685 setExpandedImage(image);1699 setExpandedImage(image);
1686 }, []);1700 }, []);
1687 const onToggleDiff = useCallback(() => {1701 const onToggleDiff = useCallback(() => {
1688 dispatch({ type: "TOGGLE_DIFF" });1702 if (state.diffOpen) {
1689 }, [dispatch]);1703 dispatch({ type: "CLOSE_DIFF" });
1704 return;
1705 }
1706
1707 const latestSummary = turnDiffSummaries[0];
1708 if (!activeThread || !latestSummary) {
1709 dispatch({ type: "TOGGLE_DIFF" });
1710 return;
1711 }
1712
1713 dispatch({
1714 type: "OPEN_DIFF",
1715 threadId: activeThread.id,
1716 turnId: latestSummary.turnId,
1717 ...(latestSummary.files[0]?.path ? { filePath: latestSummary.files[0].path } : {}),
1718 });
1719 }, [activeThread, dispatch, state.diffOpen, turnDiffSummaries]);
1720 const onOpenTurnDiff = useCallback(
1721 (turnId: string, filePath?: string) => {
1722 if (!activeThread) return;
1723 dispatch({
1724 type: "OPEN_DIFF",
1725 threadId: activeThread.id,
1726 turnId,
1727 ...(filePath ? { filePath } : {}),
1728 });
1729 },
1730 [activeThread, dispatch],
1731 );
16901732
1691 // Empty state: no active thread1733 // Empty state: no active thread
1692 if (!activeThread) {1734 if (!activeThread) {
...@@ -1761,9 +1803,11 @@ export default function ChatView() {...@@ -1761,9 +1803,11 @@ export default function ChatView() {
1761 completionDividerBeforeEntryId={completionDividerBeforeEntryId}1803 completionDividerBeforeEntryId={completionDividerBeforeEntryId}
1762 completionSummary={completionSummary}1804 completionSummary={completionSummary}
1763 assistantCompletionByItemId={assistantCompletionByItemId}1805 assistantCompletionByItemId={assistantCompletionByItemId}
1806 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
1764 nowIso={nowIso}1807 nowIso={nowIso}
1765 expandedWorkGroups={expandedWorkGroups}1808 expandedWorkGroups={expandedWorkGroups}
1766 onToggleWorkGroup={onToggleWorkGroup}1809 onToggleWorkGroup={onToggleWorkGroup}
1810 onOpenTurnDiff={onOpenTurnDiff}
1767 onImageExpand={onExpandTimelineImage}1811 onImageExpand={onExpandTimelineImage}
1768 messagesEndRef={messagesEndRef}1812 messagesEndRef={messagesEndRef}
1769 />1813 />
...@@ -2268,9 +2312,11 @@ interface MessagesTimelineProps {...@@ -2268,9 +2312,11 @@ interface MessagesTimelineProps {
2268 completionDividerBeforeEntryId: string | null;2312 completionDividerBeforeEntryId: string | null;
2269 completionSummary: string | null;2313 completionSummary: string | null;
2270 assistantCompletionByItemId: Map<string, string>;2314 assistantCompletionByItemId: Map<string, string>;
2315 turnDiffSummaryByAssistantMessageId: Map<string, TurnDiffSummary>;
2271 nowIso: string;2316 nowIso: string;
2272 expandedWorkGroups: Record<string, boolean>;2317 expandedWorkGroups: Record<string, boolean>;
2273 onToggleWorkGroup: (groupId: string) => void;2318 onToggleWorkGroup: (groupId: string) => void;
2319 onOpenTurnDiff: (turnId: string, filePath?: string) => void;
2274 onImageExpand: (image: ExpandedImagePreview) => void;2320 onImageExpand: (image: ExpandedImagePreview) => void;
2275 messagesEndRef: RefObject<HTMLDivElement | null>;2321 messagesEndRef: RefObject<HTMLDivElement | null>;
2276}2322}
...@@ -2282,9 +2328,11 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2282,9 +2328,11 @@ const MessagesTimeline = memo(function MessagesTimeline({
2282 completionDividerBeforeEntryId,2328 completionDividerBeforeEntryId,
2283 completionSummary,2329 completionSummary,
2284 assistantCompletionByItemId,2330 assistantCompletionByItemId,
2331 turnDiffSummaryByAssistantMessageId,
2285 nowIso,2332 nowIso,
2286 expandedWorkGroups,2333 expandedWorkGroups,
2287 onToggleWorkGroup,2334 onToggleWorkGroup,
2335 onOpenTurnDiff,
2288 onImageExpand,2336 onImageExpand,
2289 messagesEndRef,2337 messagesEndRef,
2290}: MessagesTimelineProps) {2338}: MessagesTimelineProps) {
...@@ -2446,6 +2494,39 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2446,6 +2494,39 @@ const MessagesTimeline = memo(function MessagesTimeline({
2446 (timelineEntry.message.streaming ? "" : "(empty response)")2494 (timelineEntry.message.streaming ? "" : "(empty response)")
2447 }2495 }
2448 />2496 />
2497 {(() => {
2498 const turnSummary = turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id);
2499 if (!turnSummary) return null;
2500 return (
2501 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
2502 <div className="mb-1.5 flex items-center justify-between gap-2">
2503 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
2504 Changed files ({turnSummary.files.length})
2505 </p>
2506 <Button
2507 type="button"
2508 size="xs"
2509 variant="outline"
2510 onClick={() => onOpenTurnDiff(turnSummary.turnId, turnSummary.files[0]?.path)}
2511 >
2512 View diff
2513 </Button>
2514 </div>
2515 <div className="flex flex-wrap gap-1.5">
2516 {turnSummary.files.map((file) => (
2517 <button
2518 key={`${turnSummary.turnId}:${file.path}`}
2519 type="button"
2520 className="rounded-md border border-border/70 bg-background/70 px-2 py-1 font-mono text-[11px] text-muted-foreground/80 transition-colors hover:border-border hover:text-foreground/90"
2521 onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)}
2522 >
2523 {file.path}
2524 </button>
2525 ))}
2526 </div>
2527 </div>
2528 );
2529 })()}
2449 <p className="mt-1.5 text-[10px] text-muted-foreground/30">2530 <p className="mt-1.5 text-[10px] text-muted-foreground/30">
2450 {formatMessageMeta(2531 {formatMessageMeta(
2451 timelineEntry.message.createdAt,2532 timelineEntry.message.createdAt,
apps/web/src/components/DiffPanel.tsx+123-36
...@@ -1,58 +1,145 @@...@@ -1,58 +1,145 @@
1import { PatchDiff } from "@pierre/diffs/react";
1import { XIcon } from "lucide-react";2import { XIcon } from "lucide-react";
3import { useMemo } from "react";
2import { isElectron } from "../env";4import { isElectron } from "../env";
5import { deriveTurnDiffSummaries, formatTimestamp } from "../session-logic";
3import { useStore } from "../store";6import { useStore } from "../store";
4import { Button } from "./ui/button";7import { Button } from "./ui/button";
8import { cn } from "~/lib/utils";
59
6export default function DiffPanel() {10export default function DiffPanel() {
7 const { dispatch } = useStore();11 const { state, dispatch } = useStore();
12 const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId);
13 const turnDiffSummaries = useMemo(
14 () => deriveTurnDiffSummaries(activeThread?.events ?? []),
15 [activeThread?.events],
16 );
17
18 const canApplyStoredTarget = Boolean(activeThread && state.diffThreadId === activeThread.id);
19 const selectedTurnId = canApplyStoredTarget ? state.diffTurnId : null;
20 const selectedFilePath = canApplyStoredTarget ? state.diffFilePath : null;
21 const selectedTurn =
22 turnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? turnDiffSummaries[0];
23 const selectedFile =
24 selectedTurn?.files.find((file) => file.path === selectedFilePath) ?? selectedTurn?.files[0];
25 const selectedPatch = selectedFile?.diff ?? selectedTurn?.unifiedDiff;
26
27 const selectTurn = (turnId: string) => {
28 if (!activeThread) return;
29 const turn = turnDiffSummaries.find((summary) => summary.turnId === turnId);
30 dispatch({
31 type: "SET_DIFF_TARGET",
32 threadId: activeThread.id,
33 turnId,
34 ...(turn?.files[0]?.path ? { filePath: turn.files[0].path } : {}),
35 });
36 };
37
38 const selectFile = (filePath: string) => {
39 if (!activeThread || !selectedTurn) return;
40 dispatch({
41 type: "SET_DIFF_TARGET",
42 threadId: activeThread.id,
43 turnId: selectedTurn.turnId,
44 filePath,
45 });
46 };
847
9 return (48 return (
10 <aside className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card">49 <aside className="flex h-full w-[560px] shrink-0 flex-col border-l border-border bg-card">
11 {/* Header */}
12 <div50 <div
13 className={`flex items-center justify-between border-b border-border px-4 ${isElectron ? "drag-region h-[52px]" : "py-3"}`}51 className={cn(
52 "flex items-center justify-between border-b border-border px-4",
53 isElectron ? "drag-region h-[52px]" : "py-3",
54 )}
14 >55 >
15 <h3 className="text-xs font-medium text-foreground">Uncommitted changes</h3>56 <h3 className="text-xs font-medium text-foreground">Turn diffs</h3>
16 <Button57 <Button type="button" size="icon-xs" variant="ghost" onClick={() => dispatch({ type: "CLOSE_DIFF" })}>
17 type="button"
18 size="icon-xs"
19 variant="ghost"
20 onClick={() => dispatch({ type: "TOGGLE_DIFF" })}
21 >
22 <XIcon />58 <XIcon />
23 </Button>59 </Button>
24 </div>60 </div>
2561
26 {/* Placeholder content */}62 {!activeThread ? (
27 <div className="flex-1 overflow-y-auto p-4">63 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
28 <div className="space-y-3">64 Select a thread to inspect turn diffs.
29 <div className="rounded-lg border border-border/80 bg-muted p-3">65 </div>
30 <div className="mb-2 flex items-center gap-2">66 ) : turnDiffSummaries.length === 0 ? (
31 <span className="rounded bg-emerald-500/20 px-1.5 py-0.5 text-[9px] font-medium text-emerald-400">67 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
32 M68 No completed turn file changes yet.
33 </span>69 </div>
34 <span className="font-mono text-xs text-muted-foreground/80">src/App.tsx</span>70 ) : (
35 </div>71 <>
36 <div className="space-y-0.5 font-mono text-[11px]">72 <div className="border-b border-border px-3 py-2.5">
37 <div className="text-red-400/60">- const oldValue = "hello";</div>73 <p className="mb-1.5 text-[10px] uppercase tracking-[0.12em] text-muted-foreground/60">
38 <div className="text-emerald-400/60">+ const newValue = "world";</div>74 Turns
75 </p>
76 <div className="flex flex-wrap gap-1.5">
77 {turnDiffSummaries.map((summary, index) => (
78 <button
79 key={summary.turnId}
80 type="button"
81 className={cn(
82 "rounded-md border px-2 py-1 text-left transition-colors",
83 summary.turnId === selectedTurn?.turnId
84 ? "border-border bg-accent text-accent-foreground"
85 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
86 )}
87 onClick={() => selectTurn(summary.turnId)}
88 title={summary.turnId}
89 >
90 <div className="text-[10px] font-medium">Turn {turnDiffSummaries.length - index}</div>
91 <div className="text-[10px] opacity-70">{formatTimestamp(summary.completedAt)}</div>
92 </button>
93 ))}
39 </div>94 </div>
40 </div>95 </div>
4196
42 <div className="rounded-lg border border-border/80 bg-muted p-3">97 <div className="min-h-0 flex flex-1">
43 <div className="flex items-center gap-2">98 <div className="w-[220px] shrink-0 border-r border-border px-2 py-2">
44 <span className="rounded bg-blue-500/20 px-1.5 py-0.5 text-[9px] font-medium text-blue-400">99 <p className="mb-1.5 px-1 text-[10px] uppercase tracking-[0.12em] text-muted-foreground/60">
45 A100 Files
46 </span>101 </p>
47 <span className="font-mono text-xs text-muted-foreground/80">src/utils.ts</span>102 <div className="space-y-1 overflow-y-auto">
103 {selectedTurn?.files.map((file) => (
104 <button
105 key={`${selectedTurn.turnId}:${file.path}`}
106 type="button"
107 className={cn(
108 "flex w-full items-center justify-between gap-2 rounded-md border px-2 py-1.5 text-left font-mono text-[11px] transition-colors",
109 selectedFile?.path === file.path
110 ? "border-border bg-accent text-accent-foreground"
111 : "border-border/60 bg-background/60 text-muted-foreground/85 hover:border-border hover:text-foreground/90",
112 )}
113 onClick={() => selectFile(file.path)}
114 >
115 <span className="truncate">{file.path}</span>
116 {file.kind && (
117 <span className="rounded-sm border border-border/60 px-1 py-0.5 text-[9px] uppercase tracking-[0.08em]">
118 {file.kind}
119 </span>
120 )}
121 </button>
122 ))}
123 </div>
48 </div>124 </div>
49 </div>
50 </div>
51125
52 <p className="mt-6 text-center text-[10px] text-muted-foreground/30">126 <div className="min-w-0 flex-1 overflow-auto px-3 py-2">
53 Diff integration coming soon127 {!canApplyStoredTarget && state.diffThreadId && (
54 </p>128 <p className="mb-2 text-[11px] text-muted-foreground/65">
55 </div>129 Showing diffs for the active thread.
130 </p>
131 )}
132 {!selectedPatch ? (
133 <div className="flex h-full items-center justify-center text-xs text-muted-foreground/70">
134 No patch available for this selection.
135 </div>
136 ) : (
137 <PatchDiff patch={selectedPatch} />
138 )}
139 </div>
140 </div>
141 </>
142 )}
56 </aside>143 </aside>
57 );144 );
58}145}
apps/web/src/session-logic.test.ts+104
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 type WorkLogEntry,5 type WorkLogEntry,
6 applyEventToMessages,6 applyEventToMessages,
7 derivePendingApprovals,7 derivePendingApprovals,
8 deriveTurnDiffSummaries,
8 deriveTimelineEntries,9 deriveTimelineEntries,
9 deriveWorkLogEntries,10 deriveWorkLogEntries,
10 evolveSession,11 evolveSession,
...@@ -393,6 +394,109 @@ describe("deriveWorkLogEntries", () => {...@@ -393,6 +394,109 @@ describe("deriveWorkLogEntries", () => {
393 });394 });
394});395});
395396
397describe("deriveTurnDiffSummaries", () => {
398 it("collects per-turn file changes and links them to assistant message completion", () => {
399 const summaries = deriveTurnDiffSummaries([
400 makeEvent({
401 id: "evt-turn-start",
402 method: "turn/started",
403 turnId: "turn-1",
404 createdAt: "2026-02-08T10:00:00.000Z",
405 }),
406 makeEvent({
407 id: "evt-file-change",
408 method: "item/completed",
409 turnId: "turn-1",
410 createdAt: "2026-02-08T10:00:01.000Z",
411 payload: {
412 item: {
413 id: "item-file-change",
414 type: "fileChange",
415 status: "completed",
416 changes: [
417 {
418 path: "src/a.ts",
419 kind: "modified",
420 diff: "diff --git a/src/a.ts b/src/a.ts\n@@ -1 +1 @@\n-old\n+new\n",
421 },
422 ],
423 },
424 },
425 }),
426 makeEvent({
427 id: "evt-turn-diff-updated",
428 method: "turn/diff/updated",
429 turnId: "turn-1",
430 createdAt: "2026-02-08T10:00:02.000Z",
431 payload: {
432 threadId: "thr-1",
433 turnId: "turn-1",
434 diff: [
435 "diff --git a/src/a.ts b/src/a.ts",
436 "@@ -1 +1 @@",
437 "-old",
438 "+new",
439 "diff --git a/src/b.ts b/src/b.ts",
440 "@@ -0,0 +1 @@",
441 "+created",
442 ].join("\n"),
443 },
444 }),
445 makeEvent({
446 id: "evt-agent-msg",
447 method: "item/completed",
448 turnId: "turn-1",
449 createdAt: "2026-02-08T10:00:03.000Z",
450 payload: {
451 item: {
452 id: "msg-1",
453 type: "agentMessage",
454 text: "Done",
455 },
456 },
457 }),
458 makeEvent({
459 id: "evt-turn-completed",
460 method: "turn/completed",
461 turnId: "turn-1",
462 createdAt: "2026-02-08T10:00:04.000Z",
463 payload: {
464 turn: {
465 id: "turn-1",
466 status: "completed",
467 },
468 },
469 }),
470 makeEvent({
471 id: "evt-turn-2-file-change",
472 method: "item/completed",
473 turnId: "turn-2",
474 createdAt: "2026-02-08T10:00:05.000Z",
475 payload: {
476 item: {
477 id: "item-file-change-2",
478 type: "fileChange",
479 status: "completed",
480 changes: [
481 {
482 path: "src/unfinished.ts",
483 kind: "modified",
484 diff: "diff --git a/src/unfinished.ts b/src/unfinished.ts",
485 },
486 ],
487 },
488 },
489 }),
490 ]);
491
492 expect(summaries).toHaveLength(1);
493 expect(summaries[0]?.turnId).toBe("turn-1");
494 expect(summaries[0]?.assistantMessageId).toBe("msg-1");
495 expect(summaries[0]?.files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]);
496 expect(summaries[0]?.files[0]?.kind).toBe("modified");
497 });
498});
499
396describe("derivePendingApprovals", () => {500describe("derivePendingApprovals", () => {
397 it("returns pending command/file-change approvals", () => {501 it("returns pending command/file-change approvals", () => {
398 const approvals = derivePendingApprovals([502 const approvals = derivePendingApprovals([
apps/web/src/session-logic.ts+230
...@@ -99,12 +99,31 @@ export type TimelineEntry =...@@ -99,12 +99,31 @@ export type TimelineEntry =
99 entry: WorkLogEntry;99 entry: WorkLogEntry;
100 };100 };
101101
102export interface TurnDiffFileChange {
103 path: string;
104 kind?: string;
105 diff?: string;
106}
107
108export interface TurnDiffSummary {
109 turnId: string;
110 completedAt: string;
111 status?: string;
112 files: TurnDiffFileChange[];
113 unifiedDiff?: string;
114 assistantMessageId?: string;
115}
116
102function normalizeDetail(value: string | undefined): string | undefined {117function normalizeDetail(value: string | undefined): string | undefined {
103 if (!value) return undefined;118 if (!value) return undefined;
104 const trimmed = value.trim();119 const trimmed = value.trim();
105 return trimmed.length > 0 ? trimmed : undefined;120 return trimmed.length > 0 ? trimmed : undefined;
106}121}
107122
123function asArray(value: unknown): unknown[] | undefined {
124 return Array.isArray(value) ? value : undefined;
125}
126
108function approvalDetail(event: ProviderEvent): string | undefined {127function approvalDetail(event: ProviderEvent): string | undefined {
109 const payload = asObject(event.payload);128 const payload = asObject(event.payload);
110 const command = asString(payload?.command);129 const command = asString(payload?.command);
...@@ -438,6 +457,217 @@ export function deriveWorkLogEntries(...@@ -438,6 +457,217 @@ export function deriveWorkLogEntries(
438 return entries;457 return entries;
439}458}
440459
460interface MutableTurnDiffFileChange {
461 path: string;
462 kind: string | undefined;
463 diff: string | undefined;
464}
465
466interface MutableTurnDiffSummary {
467 turnId: string;
468 completedAt: string | undefined;
469 status: string | undefined;
470 unifiedDiff: string | undefined;
471 assistantMessageId: string | undefined;
472 filesByPath: Map<string, MutableTurnDiffFileChange>;
473}
474
475function parsePathFromDiff(diff: string): string | undefined {
476 const normalized = diff.replace(/\r\n/g, "\n");
477 const gitHeader = normalized.match(/^diff --git a\/(.+) b\/(.+)$/m);
478 if (gitHeader && gitHeader[2]) return gitHeader[2];
479 const bPath = normalized.match(/^\+\+\+ b\/(.+)$/m);
480 if (bPath && bPath[1]) return bPath[1];
481 const direct = normalized.match(/^\+\+\+ (.+)$/m);
482 if (!direct || !direct[1] || direct[1] === "/dev/null") {
483 return undefined;
484 }
485 return direct[1];
486}
487
488function splitUnifiedDiffByFile(diff: string): Map<string, string> {
489 const normalized = diff.replace(/\r\n/g, "\n");
490 const byPath = new Map<string, string>();
491 const headerMatches = [...normalized.matchAll(/^diff --git a\/(.+) b\/(.+)$/gm)];
492
493 if (headerMatches.length === 0) {
494 const path = parsePathFromDiff(normalized);
495 if (path) {
496 byPath.set(path, normalized.trim());
497 }
498 return byPath;
499 }
500
501 for (let index = 0; index < headerMatches.length; index += 1) {
502 const match = headerMatches[index];
503 if (!match) continue;
504 const start = match.index ?? 0;
505 const nextStart = headerMatches[index + 1]?.index ?? normalized.length;
506 const segment = normalized.slice(start, nextStart).trim();
507 const path = match[2] ?? match[1] ?? parsePathFromDiff(segment);
508 if (!path || segment.length === 0) continue;
509 byPath.set(path, segment);
510 }
511
512 return byPath;
513}
514
515function parseFileChangeEntriesFromEvent(event: ProviderEvent): TurnDiffFileChange[] {
516 if (event.method !== "item/started" && event.method !== "item/completed") {
517 return [];
518 }
519
520 const payload = asObject(event.payload);
521 const item = asObject(payload?.item);
522 if (asString(item?.type) !== "fileChange") {
523 return [];
524 }
525
526 const changes = asArray(item?.changes);
527 if (!changes || changes.length === 0) {
528 return [];
529 }
530
531 const files: TurnDiffFileChange[] = [];
532 for (const rawChange of changes) {
533 const change = asObject(rawChange);
534 if (!change) continue;
535 const diff = normalizeDetail(asString(change.diff));
536 const path = normalizeDetail(asString(change.path)) ?? (diff ? parsePathFromDiff(diff) : undefined);
537 if (!path) continue;
538 const kind = normalizeDetail(asString(change.kind));
539 files.push({
540 path,
541 ...(kind ? { kind } : {}),
542 ...(diff ? { diff } : {}),
543 });
544 }
545
546 return files;
547}
548
549export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummary[] {
550 const ordered = [...events].toReversed();
551 const byTurnId = new Map<string, MutableTurnDiffSummary>();
552
553 const ensureSummary = (turnId: string): MutableTurnDiffSummary => {
554 const existing = byTurnId.get(turnId);
555 if (existing) return existing;
556 const next: MutableTurnDiffSummary = {
557 turnId,
558 completedAt: undefined,
559 status: undefined,
560 unifiedDiff: undefined,
561 assistantMessageId: undefined,
562 filesByPath: new Map(),
563 };
564 byTurnId.set(turnId, next);
565 return next;
566 };
567
568 for (const event of ordered) {
569 const turnId = eventTurnId(event);
570 if (!turnId) continue;
571 const summary = ensureSummary(turnId);
572
573 if (event.method === "turn/completed") {
574 summary.completedAt = event.createdAt;
575 const turn = asObject(asObject(event.payload)?.turn);
576 summary.status = normalizeDetail(asString(turn?.status));
577 continue;
578 }
579
580 if (event.method === "turn/diff/updated") {
581 const diff = normalizeDetail(asString(asObject(event.payload)?.diff));
582 if (diff) summary.unifiedDiff = diff;
583 continue;
584 }
585
586 if (event.method === "item/completed") {
587 const item = asObject(asObject(event.payload)?.item);
588 if (asString(item?.type) === "agentMessage") {
589 const itemId = normalizeDetail(asString(item?.id));
590 if (itemId) {
591 summary.assistantMessageId = itemId;
592 }
593 }
594 }
595
596 for (const change of parseFileChangeEntriesFromEvent(event)) {
597 const existing = summary.filesByPath.get(change.path);
598 if (!existing) {
599 summary.filesByPath.set(change.path, {
600 path: change.path,
601 kind: change.kind,
602 diff: change.diff,
603 });
604 continue;
605 }
606 summary.filesByPath.set(change.path, {
607 path: change.path,
608 kind: change.kind ?? existing.kind,
609 diff: change.diff ?? existing.diff,
610 });
611 }
612 }
613
614 const summaries: TurnDiffSummary[] = [];
615 for (const summary of byTurnId.values()) {
616 if (!summary.completedAt) continue;
617
618 const filesByPath = new Map(summary.filesByPath);
619 if (summary.unifiedDiff) {
620 const fileDiffs = splitUnifiedDiffByFile(summary.unifiedDiff);
621 for (const [path, diff] of fileDiffs) {
622 const existing = filesByPath.get(path);
623 if (!existing) {
624 filesByPath.set(path, { path, kind: undefined, diff });
625 continue;
626 }
627 if (!existing.diff) {
628 filesByPath.set(path, { ...existing, diff });
629 }
630 }
631 }
632
633 const files: TurnDiffFileChange[] = [];
634 for (const file of Array.from(filesByPath.values()).toSorted((a, b) =>
635 a.path.localeCompare(b.path),
636 )) {
637 const nextFile: TurnDiffFileChange = { path: file.path };
638 if (file.kind) {
639 nextFile.kind = file.kind;
640 }
641 if (file.diff) {
642 nextFile.diff = file.diff;
643 }
644 files.push(nextFile);
645 }
646
647 if (files.length === 0) continue;
648
649 summaries.push({
650 turnId: summary.turnId,
651 completedAt: summary.completedAt,
652 ...(summary.status ? { status: summary.status } : {}),
653 files,
654 ...(summary.unifiedDiff ? { unifiedDiff: summary.unifiedDiff } : {}),
655 ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}),
656 });
657 }
658
659 summaries.sort((a, b) => {
660 const aTime = Date.parse(a.completedAt);
661 const bTime = Date.parse(b.completedAt);
662 if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
663 return b.completedAt.localeCompare(a.completedAt);
664 }
665 return bTime - aTime;
666 });
667
668 return summaries;
669}
670
441function toTimestamp(isoDate: string): number {671function toTimestamp(isoDate: string): number {
442 const parsed = Date.parse(isoDate);672 const parsed = Date.parse(isoDate);
443 return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed;673 return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed;
apps/web/src/store.test.ts+24
...@@ -81,6 +81,9 @@ function makeState(thread: Thread): AppState {...@@ -81,6 +81,9 @@ function makeState(thread: Thread): AppState {
81 activeThreadId: thread.id,81 activeThreadId: thread.id,
82 runtimeMode: "full-access",82 runtimeMode: "full-access",
83 diffOpen: false,83 diffOpen: false,
84 diffThreadId: null,
85 diffTurnId: null,
86 diffFilePath: null,
84 };87 };
85}88}
8689
...@@ -145,6 +148,21 @@ describe("store reducer thread continuity", () => {...@@ -145,6 +148,21 @@ describe("store reducer thread continuity", () => {
145 expect(next.threads[0]?.terminalOpen).toBe(true);148 expect(next.threads[0]?.terminalOpen).toBe(true);
146 });149 });
147150
151 it("opens diff panel with an explicit turn/file target", () => {
152 const state = makeState(makeThread());
153 const next = reducer(state, {
154 type: "OPEN_DIFF",
155 threadId: "thread-local-1",
156 turnId: "turn-1",
157 filePath: "src/app.ts",
158 });
159
160 expect(next.diffOpen).toBe(true);
161 expect(next.diffThreadId).toBe("thread-local-1");
162 expect(next.diffTurnId).toBe("turn-1");
163 expect(next.diffFilePath).toBe("src/app.ts");
164 });
165
148 it("sets terminal open state per thread", () => {166 it("sets terminal open state per thread", () => {
149 const state = makeState(makeThread({ terminalOpen: true }));167 const state = makeState(makeThread({ terminalOpen: true }));
150 const next = reducer(state, {168 const next = reducer(state, {
...@@ -532,6 +550,9 @@ describe("store reducer thread continuity", () => {...@@ -532,6 +550,9 @@ describe("store reducer thread continuity", () => {
532 activeThreadId: "thread-b",550 activeThreadId: "thread-b",
533 runtimeMode: "full-access",551 runtimeMode: "full-access",
534 diffOpen: false,552 diffOpen: false,
553 diffThreadId: null,
554 diffTurnId: null,
555 diffFilePath: null,
535 };556 };
536557
537 const next = reducer(state, {558 const next = reducer(state, {
...@@ -661,6 +682,9 @@ describe("store reducer thread continuity", () => {...@@ -661,6 +682,9 @@ describe("store reducer thread continuity", () => {
661 activeThreadId: "thread-a",682 activeThreadId: "thread-a",
662 runtimeMode: "full-access",683 runtimeMode: "full-access",
663 diffOpen: false,684 diffOpen: false,
685 diffThreadId: null,
686 diffTurnId: null,
687 diffFilePath: null,
664 };688 };
665689
666 const next = reducer(state, {690 const next = reducer(state, {
apps/web/src/store.ts+62-2
...@@ -42,6 +42,19 @@ type Action =...@@ -42,6 +42,19 @@ type Action =
42 | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string }42 | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string }
43 | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string }43 | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string }
44 | { type: "TOGGLE_DIFF" }44 | { type: "TOGGLE_DIFF" }
45 | {
46 type: "OPEN_DIFF";
47 threadId: string;
48 turnId?: string;
49 filePath?: string;
50 }
51 | {
52 type: "SET_DIFF_TARGET";
53 threadId: string;
54 turnId?: string;
55 filePath?: string;
56 }
57 | { type: "CLOSE_DIFF" }
45 | {58 | {
46 type: "APPLY_EVENT";59 type: "APPLY_EVENT";
47 event: ProviderEvent;60 event: ProviderEvent;
...@@ -83,6 +96,9 @@ export interface AppState {...@@ -83,6 +96,9 @@ export interface AppState {
83 activeThreadId: string | null;96 activeThreadId: string | null;
84 runtimeMode: RuntimeMode;97 runtimeMode: RuntimeMode;
85 diffOpen: boolean;98 diffOpen: boolean;
99 diffThreadId: string | null;
100 diffTurnId: string | null;
101 diffFilePath: string | null;
86}102}
87103
88const PERSISTED_STATE_KEY = "t3code:renderer-state:v7";104const PERSISTED_STATE_KEY = "t3code:renderer-state:v7";
...@@ -103,6 +119,9 @@ const initialState: AppState = {...@@ -103,6 +119,9 @@ const initialState: AppState = {
103 activeThreadId: null,119 activeThreadId: null,
104 runtimeMode: DEFAULT_RUNTIME_MODE,120 runtimeMode: DEFAULT_RUNTIME_MODE,
105 diffOpen: false,121 diffOpen: false,
122 diffThreadId: null,
123 diffTurnId: null,
124 diffFilePath: null,
106};125};
107126
108// ── Helpers ──────────────────────────────────────────────────────────127// ── Helpers ──────────────────────────────────────────────────────────
...@@ -125,7 +144,15 @@ function readPersistedState(): AppState {...@@ -125,7 +144,15 @@ function readPersistedState(): AppState {
125 ? hydrated.activeThreadId144 ? hydrated.activeThreadId
126 : (threads[0]?.id ?? null);145 : (threads[0]?.id ?? null);
127146
128 return { ...hydrated, threads, activeThreadId, diffOpen: false };147 return {
148 ...hydrated,
149 threads,
150 activeThreadId,
151 diffOpen: false,
152 diffThreadId: null,
153 diffTurnId: null,
154 diffFilePath: null,
155 };
129 } catch {156 } catch {
130 return initialState;157 return initialState;
131 }158 }
...@@ -717,6 +744,26 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -717,6 +744,26 @@ export function reducer(state: AppState, action: Action): AppState {
717 case "TOGGLE_DIFF":744 case "TOGGLE_DIFF":
718 return { ...state, diffOpen: !state.diffOpen };745 return { ...state, diffOpen: !state.diffOpen };
719746
747 case "OPEN_DIFF":
748 return {
749 ...state,
750 diffOpen: true,
751 diffThreadId: action.threadId,
752 diffTurnId: action.turnId ?? null,
753 diffFilePath: action.filePath ?? null,
754 };
755
756 case "SET_DIFF_TARGET":
757 return {
758 ...state,
759 diffThreadId: action.threadId,
760 diffTurnId: action.turnId ?? null,
761 diffFilePath: action.filePath ?? null,
762 };
763
764 case "CLOSE_DIFF":
765 return { ...state, diffOpen: false };
766
720 case "APPLY_TERMINAL_EVENT":767 case "APPLY_TERMINAL_EVENT":
721 if (!state.threads.some((thread) => thread.id === action.event.threadId)) {768 if (!state.threads.some((thread) => thread.id === action.event.threadId)) {
722 return state;769 return state;
...@@ -901,7 +948,20 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -901,7 +948,20 @@ export function reducer(state: AppState, action: Action): AppState {
901 const threads = state.threads.filter((t) => t.id !== action.threadId);948 const threads = state.threads.filter((t) => t.id !== action.threadId);
902 const activeThreadId =949 const activeThreadId =
903 state.activeThreadId === action.threadId ? (threads[0]?.id ?? null) : state.activeThreadId;950 state.activeThreadId === action.threadId ? (threads[0]?.id ?? null) : state.activeThreadId;
904 return { ...state, threads, activeThreadId };951 const shouldClearDiffTarget = state.diffThreadId === action.threadId;
952 return {
953 ...state,
954 threads,
955 activeThreadId,
956 ...(shouldClearDiffTarget
957 ? {
958 diffOpen: false,
959 diffThreadId: null,
960 diffTurnId: null,
961 diffFilePath: null,
962 }
963 : {}),
964 };
905 }965 }
906966
907 default:967 default:
bun.lock+39
...@@ -52,6 +52,7 @@...@@ -52,6 +52,7 @@
52 "version": "0.0.0",52 "version": "0.0.0",
53 "dependencies": {53 "dependencies": {
54 "@base-ui/react": "^1.2.0",54 "@base-ui/react": "^1.2.0",
55 "@pierre/diffs": "^1.0.11",
55 "@t3tools/contracts": "workspace:*",56 "@t3tools/contracts": "workspace:*",
56 "@tanstack/react-pacer": "^0.19.4",57 "@tanstack/react-pacer": "^0.19.4",
57 "@tanstack/react-query": "^5.90.0",58 "@tanstack/react-query": "^5.90.0",
...@@ -304,6 +305,8 @@...@@ -304,6 +305,8 @@
304305
305 "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.46.0", "", { "os": "win32", "cpu": "x64" }, "sha512-DJbQsSJUr4KSi9uU0QqOgI7PX2C+fKGZX+YDprt3vM2sC0dWZsgVTLoN2vtkNyEWJSY2mnvRFUshWXT3bmo0Ug=="],306 "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.46.0", "", { "os": "win32", "cpu": "x64" }, "sha512-DJbQsSJUr4KSi9uU0QqOgI7PX2C+fKGZX+YDprt3vM2sC0dWZsgVTLoN2vtkNyEWJSY2mnvRFUshWXT3bmo0Ug=="],
306307
308 "@pierre/diffs": ["@pierre/diffs@1.0.11", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "^3.0.0", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-j6zIEoyImQy1HfcJqbrDwP0O5I7V2VNXAaw53FqQ+SykRfaNwABeZHs9uibXO4supaXPmTx6LEH9Lffr03e1Tw=="],
309
307 "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],310 "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
308311
309 "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.3", "", { "os": "android", "cpu": "arm64" }, "sha512-0T1k9FinuBZ/t7rZ8jN6OpUKPnUjNdYHoj/cESWrQ3ZraAJ4OMm6z7QjSfCxqj8mOp9kTKc1zHK3kGz5vMu+nQ=="],312 "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.3", "", { "os": "android", "cpu": "arm64" }, "sha512-0T1k9FinuBZ/t7rZ8jN6OpUKPnUjNdYHoj/cESWrQ3ZraAJ4OMm6z7QjSfCxqj8mOp9kTKc1zHK3kGz5vMu+nQ=="],
...@@ -384,6 +387,22 @@...@@ -384,6 +387,22 @@
384387
385 "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],388 "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
386389
390 "@shikijs/core": ["@shikijs/core@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA=="],
391
392 "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw=="],
393
394 "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA=="],
395
396 "@shikijs/langs": ["@shikijs/langs@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0" } }, "sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA=="],
397
398 "@shikijs/themes": ["@shikijs/themes@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0" } }, "sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g=="],
399
400 "@shikijs/transformers": ["@shikijs/transformers@3.22.0", "", { "dependencies": { "@shikijs/core": "3.22.0", "@shikijs/types": "3.22.0" } }, "sha512-E7eRV7mwDBjueLF6852n2oYeJYxBq3NSsDk+uyruYAXONv4U8holGmIrT+mPRJQ1J1SNOH6L8G19KRzmBawrFw=="],
401
402 "@shikijs/types": ["@shikijs/types@3.22.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg=="],
403
404 "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
405
387 "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],406 "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
388407
389 "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],408 "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
...@@ -622,6 +641,8 @@...@@ -622,6 +641,8 @@
622641
623 "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],642 "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
624643
644 "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
645
625 "dts-resolver": ["dts-resolver@2.1.3", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw=="],646 "dts-resolver": ["dts-resolver@2.1.3", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw=="],
626647
627 "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],648 "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
...@@ -722,6 +743,8 @@...@@ -722,6 +743,8 @@
722743
723 "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="],744 "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="],
724745
746 "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
747
725 "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],748 "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
726749
727 "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="],750 "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="],
...@@ -736,6 +759,8 @@...@@ -736,6 +759,8 @@
736759
737 "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],760 "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
738761
762 "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
763
739 "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],764 "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
740765
741 "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="],766 "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="],
...@@ -822,6 +847,8 @@...@@ -822,6 +847,8 @@
822847
823 "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],848 "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
824849
850 "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="],
851
825 "lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="],852 "lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="],
826853
827 "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],854 "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
...@@ -952,6 +979,10 @@...@@ -952,6 +979,10 @@
952979
953 "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],980 "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
954981
982 "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
983
984 "oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="],
985
955 "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],986 "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
956987
957 "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],988 "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],
...@@ -998,6 +1029,12 @@...@@ -998,6 +1029,12 @@
9981029
999 "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],1030 "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
10001031
1032 "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
1033
1034 "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
1035
1036 "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
1037
1001 "rehype-highlight": ["rehype-highlight@7.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-text": "^4.0.0", "lowlight": "^3.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA=="],1038 "rehype-highlight": ["rehype-highlight@7.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-text": "^4.0.0", "lowlight": "^3.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA=="],
10021039
1003 "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],1040 "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
...@@ -1046,6 +1083,8 @@...@@ -1046,6 +1083,8 @@
10461083
1047 "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],1084 "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
10481085
1086 "shiki": ["shiki@3.22.0", "", { "dependencies": { "@shikijs/core": "3.22.0", "@shikijs/engine-javascript": "3.22.0", "@shikijs/engine-oniguruma": "3.22.0", "@shikijs/langs": "3.22.0", "@shikijs/themes": "3.22.0", "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g=="],
1087
1049 "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],1088 "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
10501089
1051 "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],1090 "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],