authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-25 00:50:01-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
logce96ad0b61b58db07f6cc197464032b11b590890
treebe9c5e3a7a646d4c4aec0c3cfc2182278f98dc02
parent9ef1c0c9a3c056fd0ed309b5b0ba0cda72848dd0

UI fixes


15 files changed, 1202 insertions(+), 542 deletions(-)

apps/server/src/open.test.ts created+47
...@@ -0,0 +1,47 @@
1import { describe, expect, it } from "vitest";
2
3import { launchDetached, resolveEditorLaunch } from "./open";
4
5describe("resolveEditorLaunch", () => {
6 it("returns cursor command for cursor editor", () => {
7 expect(resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "cursor" }, "darwin")).toEqual({
8 command: "cursor",
9 args: ["/tmp/workspace"],
10 });
11 });
12
13 it("maps file-manager editor to OS open commands", () => {
14 expect(resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "file-manager" }, "darwin")).toEqual(
15 {
16 command: "open",
17 args: ["/tmp/workspace"],
18 },
19 );
20 expect(resolveEditorLaunch({ cwd: "C:\\workspace", editor: "file-manager" }, "win32")).toEqual(
21 {
22 command: "explorer",
23 args: ["C:\\workspace"],
24 },
25 );
26 expect(resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "file-manager" }, "linux")).toEqual(
27 {
28 command: "xdg-open",
29 args: ["/tmp/workspace"],
30 },
31 );
32 });
33});
34
35describe("launchDetached", () => {
36 it("resolves when command can be spawned", async () => {
37 await expect(launchDetached(process.execPath, ["-e", "process.exit(0)"])).resolves.toBe(
38 undefined,
39 );
40 });
41
42 it("rejects when command does not exist", async () => {
43 await expect(
44 launchDetached(`t3code-no-such-command-${Date.now()}`, []),
45 ).rejects.toBeInstanceOf(Error);
46 });
47});
apps/server/src/open.ts+28-11
...@@ -2,7 +2,6 @@ import { spawn } from "node:child_process";...@@ -2,7 +2,6 @@ import { spawn } from "node:child_process";
22
3import { EDITORS, type EditorId } from "@t3tools/contracts";3import { EDITORS, type EditorId } from "@t3tools/contracts";
4import { ServiceMap, Schema, Effect, Layer } from "effect";4import { ServiceMap, Schema, Effect, Layer } from "effect";
5import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
65
7export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", {6export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", {
8 message: Schema.String,7 message: Schema.String,
...@@ -26,7 +25,7 @@ export interface OpenShape {...@@ -26,7 +25,7 @@ export interface OpenShape {
2625
27export class Open extends ServiceMap.Service<Open, OpenShape>()("server/Open") {}26export class Open extends ServiceMap.Service<Open, OpenShape>()("server/Open") {}
2827
29function resolveEditorLaunch(28export function resolveEditorLaunch(
30 input: OpenInEditorInput,29 input: OpenInEditorInput,
31 platform: NodeJS.Platform = process.platform,30 platform: NodeJS.Platform = process.platform,
32): EditorLaunch {31): EditorLaunch {
...@@ -53,9 +52,25 @@ function resolveEditorLaunch(...@@ -53,9 +52,25 @@ function resolveEditorLaunch(
53 }52 }
54}53}
5554
55export function launchDetached(command: string, args: ReadonlyArray<string>): Promise<void> {
56 return new Promise((resolve, reject) => {
57 const child = spawn(command, [...args], {
58 detached: true,
59 stdio: "ignore",
60 });
61
62 const handleSpawn = () => {
63 child.unref();
64 resolve();
65 };
66
67 child.once("spawn", handleSpawn);
68 child.once("error", (error) => reject(error));
69 });
70}
71
56const make = Effect.gen(function* () {72const make = Effect.gen(function* () {
57 const open = yield* Effect.promise(() => import("open"));73 const open = yield* Effect.promise(() => import("open"));
58 const { spawn } = yield* ChildProcessSpawner.ChildProcessSpawner;
5974
60 return {75 return {
61 openBrowser: (target) =>76 openBrowser: (target) =>
...@@ -64,14 +79,16 @@ const make = Effect.gen(function* () {...@@ -64,14 +79,16 @@ const make = Effect.gen(function* () {
64 catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }),79 catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }),
65 }),80 }),
66 openInEditor: (input) =>81 openInEditor: (input) =>
67 Effect.gen(function* () {82 Effect.tryPromise({
68 const launch = resolveEditorLaunch(input);83 try: () => {
69 const child = yield* spawn(ChildProcess.make(launch.command, launch.args));84 const launch = resolveEditorLaunch(input);
70 yield* Effect.forkDetach(child.exitCode);85 return launchDetached(launch.command, launch.args);
71 }).pipe(86 },
72 Effect.catch((error) => Effect.logError("Failed to open editor", { cause: error })),87 catch: (cause) =>
73 Effect.scoped,88 cause instanceof OpenError
74 ),89 ? cause
90 : new OpenError({ message: "Failed to open editor", cause }),
91 }),
75 } satisfies OpenShape;92 } satisfies OpenShape;
76});93});
7794
apps/web/src/components/ChatMarkdown.tsx+30-7
...@@ -1,20 +1,43 @@...@@ -1,20 +1,43 @@
1import { memo } from "react";1import { memo, useMemo } from "react";
2import type { Components } from "react-markdown";2import type { Components } from "react-markdown";
3import ReactMarkdown from "react-markdown";3import ReactMarkdown from "react-markdown";
4import rehypeHighlight from "rehype-highlight";4import rehypeHighlight from "rehype-highlight";
5import remarkGfm from "remark-gfm";5import remarkGfm from "remark-gfm";
6import { useNativeApi } from "../hooks/useNativeApi";
7import { resolveMarkdownFileLinkTarget } from "../markdown-links";
8import { preferredTerminalEditor } from "../terminal-links";
69
7interface ChatMarkdownProps {10interface ChatMarkdownProps {
8 text: string;11 text: string;
12 cwd: string | undefined;
9}13}
1014
11const markdownComponents: Components = {15function ChatMarkdown({ text, cwd }: ChatMarkdownProps) {
12 a({ node: _node, ...props }) {16 const api = useNativeApi();
13 return <a {...props} target="_blank" rel="noreferrer" />;17 const markdownComponents = useMemo<Components>(
14 },18 () => ({
15};19 a({ node: _node, href, ...props }) {
20 const targetPath = resolveMarkdownFileLinkTarget(href, cwd);
21 if (!targetPath || !api) {
22 return <a {...props} href={href} target="_blank" rel="noreferrer" />;
23 }
24
25 return (
26 <a
27 {...props}
28 href={href}
29 onClick={(event) => {
30 event.preventDefault();
31 event.stopPropagation();
32 void api.shell.openInEditor(targetPath, preferredTerminalEditor());
33 }}
34 />
35 );
36 },
37 }),
38 [api, cwd],
39 );
1640
17function ChatMarkdown({ text }: ChatMarkdownProps) {
18 return (41 return (
19 <div className="chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80">42 <div className="chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80">
20 <ReactMarkdown43 <ReactMarkdown
apps/web/src/components/ChatView.tsx+72-58
...@@ -344,7 +344,10 @@ interface ChatViewProps {...@@ -344,7 +344,10 @@ interface ChatViewProps {
344export default function ChatView({ threadId }: ChatViewProps) {344export default function ChatView({ threadId }: ChatViewProps) {
345 const { state, dispatch } = useStore();345 const { state, dispatch } = useStore();
346 const navigate = useNavigate();346 const navigate = useNavigate();
347 const rawSearch = useSearch({ strict: false });347 const rawSearch = useSearch({
348 strict: false,
349 select: (params) => parseDiffRouteSearch(params),
350 });
348 const api = useNativeApi();351 const api = useNativeApi();
349 const { resolvedTheme } = useTheme();352 const { resolvedTheme } = useTheme();
350 const queryClient = useQueryClient();353 const queryClient = useQueryClient();
...@@ -387,6 +390,26 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -387,6 +390,26 @@ export default function ChatView({ threadId }: ChatViewProps) {
387 const diffOpen = diffSearch.diff === "1";390 const diffOpen = diffSearch.diff === "1";
388 const activeThreadId = activeThread?.id ?? null;391 const activeThreadId = activeThread?.id ?? null;
389 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);392 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
393
394 useEffect(() => {
395 if (!activeThread?.id) return;
396 if (!activeThread.latestTurnCompletedAt) return;
397 const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt);
398 if (Number.isNaN(turnCompletedAt)) return;
399 const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN;
400 if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return;
401
402 dispatch({
403 type: "MARK_THREAD_VISITED",
404 threadId: activeThread.id,
405 });
406 }, [
407 activeThread?.id,
408 activeThread?.lastVisitedAt,
409 activeThread?.latestTurnCompletedAt,
410 dispatch,
411 ]);
412
390 const selectedModel = resolveModelSlug(413 const selectedModel = resolveModelSlug(
391 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,414 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
392 );415 );
...@@ -799,47 +822,33 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -799,47 +822,33 @@ export default function ChatView({ threadId }: ChatViewProps) {
799 keybinding?: string | null;822 keybinding?: string | null;
800 keybindingCommand: string;823 keybindingCommand: string;
801 }) => {824 }) => {
802 dispatch({825 if (!api) return;
803 type: "SET_PROJECT_SCRIPTS",826
804 projectId: input.projectId,827 await api.orchestration.dispatchCommand({
828 type: "project.meta.update",
829 commandId: newCommandId(),
830 projectId: asProjectId(input.projectId),
805 scripts: input.nextScripts,831 scripts: input.nextScripts,
806 });832 });
807833
808 if (!api) return;834 if (isElectron && input.keybinding) {
809 try {835 const keybindingUpdate = await api.server.upsertKeybinding({
810 await api.orchestration.dispatchCommand({836 key: input.keybinding,
811 type: "project.meta.update",837 command: input.keybindingCommand,
812 commandId: newCommandId(),
813 projectId: asProjectId(input.projectId),
814 scripts: input.nextScripts,
815 });838 });
816839 queryClient.setQueryData(
817 if (isElectron && input.keybinding) {840 serverQueryKeys.config(),
818 const keybindingUpdate = await api.server.upsertKeybinding({841 (current: { cwd: string; keybindings: ResolvedKeybindingsConfig } | undefined) =>
819 key: input.keybinding,842 current
820 command: input.keybindingCommand,843 ? { ...current, keybindings: keybindingUpdate.keybindings }
821 });844 : {
822 queryClient.setQueryData(845 cwd: input.projectCwd,
823 serverQueryKeys.config(),846 keybindings: keybindingUpdate.keybindings,
824 (current: { cwd: string; keybindings: ResolvedKeybindingsConfig } | undefined) =>
825 current
826 ? { ...current, keybindings: keybindingUpdate.keybindings }
827 : {
828 cwd: input.projectCwd,
829 keybindings: keybindingUpdate.keybindings,
830 },847 },
831 );848 );
832 }
833 } catch (error) {
834 dispatch({
835 type: "SET_PROJECT_SCRIPTS",
836 projectId: input.projectId,
837 scripts: input.previousScripts,
838 });
839 throw error;
840 }849 }
841 },850 },
842 [api, dispatch, queryClient],851 [api, queryClient],
843 );852 );
844 const saveProjectScript = useCallback(853 const saveProjectScript = useCallback(
845 async (input: NewProjectScriptInput) => {854 async (input: NewProjectScriptInput) => {
...@@ -1338,15 +1347,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1338,15 +1347,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1338 setIsRevertingCheckpoint(false);1347 setIsRevertingCheckpoint(false);
1339 }1348 }
1340 },1349 },
1341 [1350 [activeThread, api, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError],
1342 activeThread,
1343 api,
1344 isConnecting,
1345 isRevertingCheckpoint,
1346 isSending,
1347 phase,
1348 setThreadError,
1349 ],
1350 );1351 );
13511352
1352 const onSend = async (e: FormEvent) => {1353 const onSend = async (e: FormEvent) => {
...@@ -1425,7 +1426,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1425,7 +1426,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
1425 try {1426 try {
1426 const turnAttachments = await Promise.all(1427 const turnAttachments = await Promise.all(
1427 composerImagesSnapshot.map(1428 composerImagesSnapshot.map(
1428 async (image): Promise<{1429 async (
1430 image,
1431 ): Promise<{
1429 type: "image";1432 type: "image";
1430 name: string;1433 name: string;
1431 mimeType: string;1434 mimeType: string;
...@@ -1749,12 +1752,17 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1749,12 +1752,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
1749 onRevertUserMessage={onRevertUserMessage}1752 onRevertUserMessage={onRevertUserMessage}
1750 isRevertingCheckpoint={isRevertingCheckpoint}1753 isRevertingCheckpoint={isRevertingCheckpoint}
1751 onImageExpand={onExpandTimelineImage}1754 onImageExpand={onExpandTimelineImage}
1755 markdownCwd={gitCwd ?? undefined}
1752 />1756 />
1753 </div>1757 </div>
17541758
1755 {/* Input bar */}1759 {/* Input bar */}
1756 <div className={cn("px-3 pt-1.5 sm:px-5 sm:pt-2", isGitRepo ? "pb-1" : "pb-3 sm:pb-4")}>1760 <div className={cn("px-3 pt-1.5 sm:px-5 sm:pt-2", isGitRepo ? "pb-1" : "pb-3 sm:pb-4")}>
1757 <form onSubmit={onSend} className="mx-auto w-full max-w-3xl">1761 <form
1762 onSubmit={onSend}
1763 className="mx-auto w-full min-w-fit max-w-3xl"
1764 data-chat-composer-form="true"
1765 >
1758 <div1766 <div
1759 className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring ${1767 className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring ${
1760 isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border"1768 isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border"
...@@ -1853,7 +1861,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1853,7 +1861,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
18531861
1854 {/* Bottom toolbar */}1862 {/* Bottom toolbar */}
1855 <div className="flex flex-wrap items-center justify-between gap-2 px-2.5 pb-2.5 sm:flex-nowrap sm:gap-0 sm:px-3 sm:pb-3">1863 <div className="flex flex-wrap items-center justify-between gap-2 px-2.5 pb-2.5 sm:flex-nowrap sm:gap-0 sm:px-3 sm:pb-3">
1856 <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:overflow-visible">1864 <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible">
1857 {/* Model picker */}1865 {/* Model picker */}
1858 <ModelPicker model={selectedModel} onModelChange={onModelSelect} />1866 <ModelPicker model={selectedModel} onModelChange={onModelSelect} />
18591867
...@@ -1869,7 +1877,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1869,7 +1877,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1869 {/* Runtime mode toggle */}1877 {/* Runtime mode toggle */}
1870 <Button1878 <Button
1871 variant="ghost"1879 variant="ghost"
1872 className="shrink-0 px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"1880 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
1873 size="sm"1881 size="sm"
1874 type="button"1882 type="button"
1875 disabled={isSwitchingRuntimeMode}1883 disabled={isSwitchingRuntimeMode}
...@@ -2075,19 +2083,19 @@ const ChatHeader = memo(function ChatHeader({...@@ -2075,19 +2083,19 @@ const ChatHeader = memo(function ChatHeader({
2075 onToggleDiff,2083 onToggleDiff,
2076}: ChatHeaderProps) {2084}: ChatHeaderProps) {
2077 return (2085 return (
2078 <div className="flex min-w-0 flex-1 items-center justify-between gap-2">2086 <div className="flex min-w-0 flex-1 items-center gap-2">
2079 <div className="flex min-w-0 items-center gap-2 sm:gap-3">2087 <div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3">
2080 <SidebarTrigger className="size-7 shrink-0 md:hidden" />2088 <SidebarTrigger className="size-7 shrink-0 md:hidden" />
2081 <h2 className="truncate text-sm font-medium text-foreground" title={activeThreadTitle}>2089 <h2 className="min-w-0 shrink truncate text-sm font-medium text-foreground" title={activeThreadTitle}>
2082 {activeThreadTitle}2090 {activeThreadTitle}
2083 </h2>2091 </h2>
2084 {activeProjectName && (2092 {activeProjectName && (
2085 <Badge variant="outline" className="max-w-28 truncate sm:max-w-none">2093 <Badge variant="outline" className="max-w-28 shrink-0 truncate">
2086 {activeProjectName}2094 {activeProjectName}
2087 </Badge>2095 </Badge>
2088 )}2096 )}
2089 </div>2097 </div>
2090 <div className="flex shrink-0 items-center gap-2 sm:gap-3">2098 <div className="@container/header-actions flex min-w-0 flex-1 items-center justify-end gap-2 @sm/header-actions:gap-3">
2091 {activeProjectScripts && (2099 {activeProjectScripts && (
2092 <ProjectScriptsControl2100 <ProjectScriptsControl
2093 scripts={activeProjectScripts}2101 scripts={activeProjectScripts}
...@@ -2221,6 +2229,7 @@ interface MessagesTimelineProps {...@@ -2221,6 +2229,7 @@ interface MessagesTimelineProps {
2221 onRevertUserMessage: (messageId: string) => void;2229 onRevertUserMessage: (messageId: string) => void;
2222 isRevertingCheckpoint: boolean;2230 isRevertingCheckpoint: boolean;
2223 onImageExpand: (image: ExpandedImagePreview) => void;2231 onImageExpand: (image: ExpandedImagePreview) => void;
2232 markdownCwd: string | undefined;
2224}2233}
22252234
2226type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];2235type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];
...@@ -2256,6 +2265,7 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2256,6 +2265,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
2256 onRevertUserMessage,2265 onRevertUserMessage,
2257 isRevertingCheckpoint,2266 isRevertingCheckpoint,
2258 onImageExpand,2267 onImageExpand,
2268 markdownCwd,
2259}: MessagesTimelineProps) {2269}: MessagesTimelineProps) {
2260 const rows = useMemo<TimelineRow[]>(() => {2270 const rows = useMemo<TimelineRow[]>(() => {
2261 const nextRows: TimelineRow[] = [];2271 const nextRows: TimelineRow[] = [];
...@@ -2448,7 +2458,7 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2448,7 +2458,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
2448 </div>2458 </div>
2449 )}2459 )}
2450 {row.message.text && (2460 {row.message.text && (
2451 <pre className="whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-foreground">2461 <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground">
2452 {row.message.text}2462 {row.message.text}
2453 </pre>2463 </pre>
2454 )}2464 )}
...@@ -2490,7 +2500,7 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2490,7 +2500,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
2490 </div>2500 </div>
2491 )}2501 )}
2492 <div className="min-w-0 px-1 py-0.5">2502 <div className="min-w-0 px-1 py-0.5">
2493 <ChatMarkdown text={messageText} />2503 <ChatMarkdown text={messageText} cwd={markdownCwd} />
2494 {(() => {2504 {(() => {
2495 const turnSummary = turnDiffSummaryByAssistantMessageId.get(2505 const turnSummary = turnDiffSummaryByAssistantMessageId.get(
2496 row.message.id,2506 row.message.id,
...@@ -2567,7 +2577,9 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2567,7 +2577,9 @@ const MessagesTimeline = memo(function MessagesTimeline({
2567 <span className="ml-1 text-muted-foreground/70">(</span>2577 <span className="ml-1 text-muted-foreground/70">(</span>
2568 <span className="text-success">+{stat.additions}</span>2578 <span className="text-success">+{stat.additions}</span>
2569 <span className="mx-0.5 text-muted-foreground/70">/</span>2579 <span className="mx-0.5 text-muted-foreground/70">/</span>
2570 <span className="text-destructive">-{stat.deletions}</span>2580 <span className="text-destructive">
2581 -{stat.deletions}
2582 </span>
2571 <span className="text-muted-foreground/70">)</span>2583 <span className="text-muted-foreground/70">)</span>
2572 </>2584 </>
2573 );2585 );
...@@ -2728,9 +2740,11 @@ const OpenInPicker = memo(function OpenInPicker({...@@ -2728,9 +2740,11 @@ const OpenInPicker = memo(function OpenInPicker({
2728 <Group aria-label="Subscription actions">2740 <Group aria-label="Subscription actions">
2729 <Button size="xs" variant="outline" onClick={() => openInEditor(lastEditor)}>2741 <Button size="xs" variant="outline" onClick={() => openInEditor(lastEditor)}>
2730 {primaryOption?.Icon && <primaryOption.Icon aria-hidden="true" className="size-3.5" />}2742 {primaryOption?.Icon && <primaryOption.Icon aria-hidden="true" className="size-3.5" />}
2731 <span className="sr-only sm:not-sr-only sm:ml-0.5">Open</span>2743 <span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5">
2744 Open
2745 </span>
2732 </Button>2746 </Button>
2733 <GroupSeparator className="hidden sm:block" />2747 <GroupSeparator className="hidden @sm/header-actions:block" />
2734 <Menu>2748 <Menu>
2735 <MenuTrigger render={<Button aria-label="Copy options" size="icon-xs" variant="outline" />}>2749 <MenuTrigger render={<Button aria-label="Copy options" size="icon-xs" variant="outline" />}>
2736 <ChevronDownIcon aria-hidden="true" className="size-4" />2750 <ChevronDownIcon aria-hidden="true" className="size-4" />
apps/web/src/components/DiffPanel.tsx+35-33
...@@ -75,7 +75,7 @@ function formatTurnChipTimestamp(isoDate: string): string {...@@ -75,7 +75,7 @@ function formatTurnChipTimestamp(isoDate: string): string {
75}75}
7676
77interface DiffPanelProps {77interface DiffPanelProps {
78 mode?: "inline" | "sheet";78 mode?: "inline" | "sheet" | "sidebar";
79}79}
8080
81export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";81export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
...@@ -271,7 +271,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -271,7 +271,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
271271
272 updateTurnStripScrollState();272 updateTurnStripScrollState();
273 const onScroll = () => updateTurnStripScrollState();273 const onScroll = () => updateTurnStripScrollState();
274 274
275 element.addEventListener("scroll", onScroll, { passive: true });275 element.addEventListener("scroll", onScroll, { passive: true });
276 element.addEventListener("wheel", onTurnStripWheel, { passive: false });276 element.addEventListener("wheel", onTurnStripWheel, { passive: false });
277277
...@@ -297,10 +297,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -297,10 +297,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
297 selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });297 selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
298 }, [selectedTurn?.turnId, selectedTurnId]);298 }, [selectedTurn?.turnId, selectedTurnId]);
299299
300 const shouldUseDragRegion = isElectron && mode === "inline";300 const shouldUseDragRegion = isElectron && mode !== "sheet";
301301
302 return (302 return (
303 <aside303 <div
304 className={cn(304 className={cn(
305 "flex h-full min-w-0 flex-col bg-card",305 "flex h-full min-w-0 flex-col bg-card",
306 mode === "inline"306 mode === "inline"
...@@ -308,18 +308,19 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -308,18 +308,19 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
308 : "w-full",308 : "w-full",
309 )}309 )}
310 >310 >
311 <div311 <div className="border-b border-border">
312 className={cn(312 <div
313 "flex items-center justify-between gap-2 border-b border-border px-4",313 className={cn(
314 shouldUseDragRegion ? "drag-region h-[52px]" : "py-3",314 "flex h-12 items-center justify-between gap-2 px-4",
315 )}315 shouldUseDragRegion ? "drag-region" : undefined,
316 >316 )}
317 <div className="relative min-w-0 flex-1 [-webkit-app-region:no-drag]">317 >
318 <div className="relative min-w-0 flex-1 [-webkit-app-region:no-drag]">
318 {canScrollTurnStripLeft && (319 {canScrollTurnStripLeft && (
319 <div className="pointer-events-none absolute inset-y-0 left-8 z-10 w-7 bg-gradient-to-r from-card to-transparent" />320 <div className="pointer-events-none absolute inset-y-0 left-8 z-10 w-7 bg-linear-to-r from-card to-transparent" />
320 )}321 )}
321 {canScrollTurnStripRight && (322 {canScrollTurnStripRight && (
322 <div className="pointer-events-none absolute inset-y-0 right-8 z-10 w-7 bg-gradient-to-l from-card to-transparent" />323 <div className="pointer-events-none absolute inset-y-0 right-8 z-10 w-7 bg-linear-to-l from-card to-transparent" />
323 )}324 )}
324 <button325 <button
325 type="button"326 type="button"
...@@ -402,26 +403,27 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -402,26 +403,27 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
402 </button>403 </button>
403 ))}404 ))}
404 </div>405 </div>
406 </div>
407 <ToggleGroup
408 className="shrink-0 [-webkit-app-region:no-drag]"
409 variant="outline"
410 size="xs"
411 value={[diffRenderMode]}
412 onValueChange={(value) => {
413 const next = value[0];
414 if (next === "stacked" || next === "split") {
415 setDiffRenderMode(next);
416 }
417 }}
418 >
419 <Toggle aria-label="Stacked diff view" value="stacked">
420 <Rows3Icon className="size-3" />
421 </Toggle>
422 <Toggle aria-label="Split diff view" value="split">
423 <Columns2Icon className="size-3" />
424 </Toggle>
425 </ToggleGroup>
405 </div>426 </div>
406 <ToggleGroup
407 className="shrink-0 [-webkit-app-region:no-drag]"
408 variant="outline"
409 size="xs"
410 value={[diffRenderMode]}
411 onValueChange={(value) => {
412 const next = value[0];
413 if (next === "stacked" || next === "split") {
414 setDiffRenderMode(next);
415 }
416 }}
417 >
418 <Toggle aria-label="Stacked diff view" value="stacked">
419 <Rows3Icon className="size-3" />
420 </Toggle>
421 <Toggle aria-label="Split diff view" value="split">
422 <Columns2Icon className="size-3" />
423 </Toggle>
424 </ToggleGroup>
425 </div>427 </div>
426428
427 {!activeThread ? (429 {!activeThread ? (
...@@ -488,6 +490,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -488,6 +490,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
488 </div>490 </div>
489 </>491 </>
490 )}492 )}
491 </aside>493 </div>
492 );494 );
493}495}
apps/web/src/components/GitActionsControl.tsx+8-8
...@@ -136,11 +136,7 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {...@@ -136,11 +136,7 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
136 return <InfoIcon className={iconClassName} />;136 return <InfoIcon className={iconClassName} />;
137}137}
138138
139export default function GitActionsControl({139export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitActionsControlProps) {
140 api,
141 gitCwd,
142 activeThreadId,
143}: GitActionsControlProps) {
144 const threadToastData = useMemo(140 const threadToastData = useMemo(
145 () => (activeThreadId ? { threadId: activeThreadId } : undefined),141 () => (activeThreadId ? { threadId: activeThreadId } : undefined),
146 [activeThreadId],142 [activeThreadId],
...@@ -501,7 +497,9 @@ export default function GitActionsControl({...@@ -501,7 +497,9 @@ export default function GitActionsControl({
501 }497 }
502 >498 >
503 <GitQuickActionIcon quickAction={quickAction} />499 <GitQuickActionIcon quickAction={quickAction} />
504 <span className="sr-only sm:not-sr-only sm:ml-0.5">{quickAction.label}</span>500 <span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5">
501 {quickAction.label}
502 </span>
505 </PopoverTrigger>503 </PopoverTrigger>
506 <PopoverPopup tooltipStyle side="bottom" align="start">504 <PopoverPopup tooltipStyle side="bottom" align="start">
507 {quickActionDisabledReason}505 {quickActionDisabledReason}
...@@ -515,10 +513,12 @@ export default function GitActionsControl({...@@ -515,10 +513,12 @@ export default function GitActionsControl({
515 onClick={runQuickAction}513 onClick={runQuickAction}
516 >514 >
517 <GitQuickActionIcon quickAction={quickAction} />515 <GitQuickActionIcon quickAction={quickAction} />
518 <span className="sr-only sm:not-sr-only sm:ml-0.5">{quickAction.label}</span>516 <span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5">
517 {quickAction.label}
518 </span>
519 </Button>519 </Button>
520 )}520 )}
521 <GroupSeparator className="hidden sm:block" />521 <GroupSeparator className="hidden @sm/header-actions:block" />
522 <Menu522 <Menu
523 onOpenChange={(open) => {523 onOpenChange={(open) => {
524 if (open) void invalidateGitQueries(queryClient);524 if (open) void invalidateGitQueries(queryClient);
apps/web/src/components/ProjectScriptsControl.tsx+7-3
...@@ -266,9 +266,11 @@ export default function ProjectScriptsControl({...@@ -266,9 +266,11 @@ export default function ProjectScriptsControl({
266 title={`Run ${primaryScript.name}`}266 title={`Run ${primaryScript.name}`}
267 >267 >
268 <ScriptIcon icon={primaryScript.icon} />268 <ScriptIcon icon={primaryScript.icon} />
269 <span className="sr-only sm:not-sr-only sm:ml-0.5">{primaryScript.name}</span>269 <span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5">
270 {primaryScript.name}
271 </span>
270 </Button>272 </Button>
271 <GroupSeparator className="hidden sm:block" />273 <GroupSeparator className="hidden @sm/header-actions:block" />
272 <Menu highlightItemOnHover={false}>274 <Menu highlightItemOnHover={false}>
273 <MenuTrigger275 <MenuTrigger
274 render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />}276 render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />}
...@@ -329,7 +331,9 @@ export default function ProjectScriptsControl({...@@ -329,7 +331,9 @@ export default function ProjectScriptsControl({
329 ) : (331 ) : (
330 <Button size="xs" variant="outline" onClick={openAddDialog} title="Add action">332 <Button size="xs" variant="outline" onClick={openAddDialog} title="Add action">
331 <PlusIcon className="size-3.5" />333 <PlusIcon className="size-3.5" />
332 <span className="sr-only sm:not-sr-only sm:ml-0.5">Add action</span>334 <span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5">
335 Add action
336 </span>
333 </Button>337 </Button>
334 )}338 )}
335339
apps/web/src/components/Sidebar.tsx-3
...@@ -33,7 +33,6 @@ import {...@@ -33,7 +33,6 @@ import {
33 SidebarMenuSub,33 SidebarMenuSub,
34 SidebarMenuSubButton,34 SidebarMenuSubButton,
35 SidebarMenuSubItem,35 SidebarMenuSubItem,
36 SidebarRail,
37 SidebarSeparator,36 SidebarSeparator,
38 SidebarTrigger,37 SidebarTrigger,
39} from "./ui/sidebar";38} from "./ui/sidebar";
...@@ -710,8 +709,6 @@ export default function Sidebar() {...@@ -710,8 +709,6 @@ export default function Sidebar() {
710 </button>709 </button>
711 )}710 )}
712 </SidebarFooter>711 </SidebarFooter>
713
714 <SidebarRail />
715 </>712 </>
716 );713 );
717}714}
apps/web/src/components/ui/sidebar.tsx+377-77
...@@ -25,6 +25,7 @@ const SIDEBAR_WIDTH = "16rem";...@@ -25,6 +25,7 @@ const SIDEBAR_WIDTH = "16rem";
25const SIDEBAR_WIDTH_MOBILE = "calc(100vw - var(--spacing(3)))";25const SIDEBAR_WIDTH_MOBILE = "calc(100vw - var(--spacing(3)))";
26const SIDEBAR_WIDTH_ICON = "3rem";26const SIDEBAR_WIDTH_ICON = "3rem";
27const SIDEBAR_KEYBOARD_SHORTCUT = "b";27const SIDEBAR_KEYBOARD_SHORTCUT = "b";
28const SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH = 16 * 16;
2829
29type SidebarContextProps = {30type SidebarContextProps = {
30 state: "expanded" | "collapsed";31 state: "expanded" | "collapsed";
...@@ -36,7 +37,43 @@ type SidebarContextProps = {...@@ -36,7 +37,43 @@ type SidebarContextProps = {
36 toggleSidebar: () => void;37 toggleSidebar: () => void;
37};38};
3839
40type SidebarResizableOptions = {
41 maxWidth?: number;
42 minWidth?: number;
43 onResize?: (width: number) => void;
44 shouldAcceptWidth?: (context: {
45 currentWidth: number;
46 nextWidth: number;
47 rail: HTMLButtonElement;
48 side: "left" | "right";
49 sidebarRoot: HTMLElement;
50 wrapper: HTMLElement;
51 }) => boolean;
52 storageKey?: string;
53};
54
55type SidebarResolvedResizableOptions = {
56 maxWidth: number;
57 minWidth: number;
58 onResize?: (width: number) => void;
59 shouldAcceptWidth?: (context: {
60 currentWidth: number;
61 nextWidth: number;
62 rail: HTMLButtonElement;
63 side: "left" | "right";
64 sidebarRoot: HTMLElement;
65 wrapper: HTMLElement;
66 }) => boolean;
67 storageKey: string | null;
68};
69
70type SidebarInstanceContextProps = {
71 resizable: SidebarResolvedResizableOptions | null;
72 side: "left" | "right";
73};
74
39const SidebarContext = React.createContext<SidebarContextProps | null>(null);75const SidebarContext = React.createContext<SidebarContextProps | null>(null);
76const SidebarInstanceContext = React.createContext<SidebarInstanceContextProps | null>(null);
4077
41function useSidebar() {78function useSidebar() {
42 const context = React.useContext(SidebarContext);79 const context = React.useContext(SidebarContext);
...@@ -149,6 +186,7 @@ function Sidebar({...@@ -149,6 +186,7 @@ function Sidebar({
149 side = "left",186 side = "left",
150 variant = "sidebar",187 variant = "sidebar",
151 collapsible = "offcanvas",188 collapsible = "offcanvas",
189 resizable = false,
152 className,190 className,
153 children,191 children,
154 ...props192 ...props
...@@ -156,98 +194,123 @@ function Sidebar({...@@ -156,98 +194,123 @@ function Sidebar({
156 side?: "left" | "right";194 side?: "left" | "right";
157 variant?: "sidebar" | "floating" | "inset";195 variant?: "sidebar" | "floating" | "inset";
158 collapsible?: "offcanvas" | "icon" | "none";196 collapsible?: "offcanvas" | "icon" | "none";
197 resizable?: boolean | SidebarResizableOptions;
159}) {198}) {
160 const { isMobile, state, openMobile, setOpenMobile } = useSidebar();199 const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
200 const resolvedResizable = React.useMemo<SidebarResolvedResizableOptions | null>(() => {
201 if (isMobile || collapsible === "none" || !resizable) {
202 return null;
203 }
204
205 const options = typeof resizable === "boolean" ? {} : resizable;
206 return {
207 maxWidth: options.maxWidth ?? Number.POSITIVE_INFINITY,
208 minWidth: options.minWidth ?? SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH,
209 onResize: options.onResize,
210 shouldAcceptWidth: options.shouldAcceptWidth,
211 storageKey: options.storageKey ?? null,
212 };
213 }, [collapsible, isMobile, resizable]);
214 const instanceContextValue = React.useMemo<SidebarInstanceContextProps>(
215 () => ({ side, resizable: resolvedResizable }),
216 [resolvedResizable, side],
217 );
161218
162 if (collapsible === "none") {219 if (collapsible === "none") {
163 return (220 return (
164 <div221 <SidebarInstanceContext.Provider value={instanceContextValue}>
165 className={cn(222 <div
166 "flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
167 className,
168 )}
169 data-slot="sidebar"
170 {...props}
171 >
172 {children}
173 </div>
174 );
175 }
176
177 if (isMobile) {
178 return (
179 <Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}>
180 <SheetPopup
181 className={cn(223 className={cn(
182 "w-(--sidebar-width) max-w-none bg-sidebar p-0 text-sidebar-foreground",224 "flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
183 className,225 className,
184 )}226 )}
185 data-mobile="true"
186 data-sidebar="sidebar"
187 data-slot="sidebar"227 data-slot="sidebar"
188 showCloseButton={false}228 {...props}
189 side={side}
190 style={
191 {
192 "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
193 } as React.CSSProperties
194 }
195 >229 >
196 <SheetHeader className="sr-only">230 {children}
197 <SheetTitle>Sidebar</SheetTitle>231 </div>
198 <SheetDescription>Displays the mobile sidebar.</SheetDescription>232 </SidebarInstanceContext.Provider>
199 </SheetHeader>233 );
200 <div className="flex h-full w-full flex-col">{children}</div>234 }
201 </SheetPopup>235
202 </Sheet>236 if (isMobile) {
237 return (
238 <SidebarInstanceContext.Provider value={instanceContextValue}>
239 <Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}>
240 <SheetPopup
241 className={cn(
242 "w-(--sidebar-width) max-w-none bg-sidebar p-0 text-sidebar-foreground",
243 className,
244 )}
245 data-mobile="true"
246 data-sidebar="sidebar"
247 data-slot="sidebar"
248 showCloseButton={false}
249 side={side}
250 style={
251 {
252 "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
253 } as React.CSSProperties
254 }
255 >
256 <SheetHeader className="sr-only">
257 <SheetTitle>Sidebar</SheetTitle>
258 <SheetDescription>Displays the mobile sidebar.</SheetDescription>
259 </SheetHeader>
260 <div className="flex h-full w-full flex-col">{children}</div>
261 </SheetPopup>
262 </Sheet>
263 </SidebarInstanceContext.Provider>
203 );264 );
204 }265 }
205266
206 return (267 return (
207 <div268 <SidebarInstanceContext.Provider value={instanceContextValue}>
208 className="group peer hidden text-sidebar-foreground md:block"
209 data-collapsible={state === "collapsed" ? collapsible : ""}
210 data-side={side}
211 data-slot="sidebar"
212 data-state={state}
213 data-variant={variant}
214 >
215 {/* This is what handles the sidebar gap on desktop */}
216 <div269 <div
217 className={cn(270 className="group peer hidden text-sidebar-foreground md:block"
218 "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",271 data-collapsible={state === "collapsed" ? collapsible : ""}
219 "group-data-[collapsible=offcanvas]:w-0",272 data-side={side}
220 "group-data-[side=right]:rotate-180",273 data-slot="sidebar"
221 variant === "floating" || variant === "inset"274 data-state={state}
222 ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"275 data-variant={variant}
223 : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
224 )}
225 data-slot="sidebar-gap"
226 />
227 <div
228 className={cn(
229 "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
230 side === "left"
231 ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
232 : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
233 // Adjust the padding for floating and inset variants.
234 variant === "floating" || variant === "inset"
235 ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
236 : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
237 className,
238 )}
239 data-slot="sidebar-container"
240 {...props}
241 >276 >
277 {/* This is what handles the sidebar gap on desktop */}
242 <div278 <div
243 className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm/5"279 className={cn(
244 data-sidebar="sidebar"280 "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
245 data-slot="sidebar-inner"281 "group-data-[collapsible=offcanvas]:w-0",
282 "group-data-[side=right]:rotate-180",
283 variant === "floating" || variant === "inset"
284 ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
285 : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
286 )}
287 data-slot="sidebar-gap"
288 />
289 <div
290 className={cn(
291 "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
292 side === "left"
293 ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
294 : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
295 // Adjust the padding for floating and inset variants.
296 variant === "floating" || variant === "inset"
297 ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
298 : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
299 className,
300 )}
301 data-slot="sidebar-container"
302 {...props}
246 >303 >
247 {children}304 <div
305 className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm/5"
306 data-sidebar="sidebar"
307 data-slot="sidebar-inner"
308 >
309 {children}
310 </div>
248 </div>311 </div>
249 </div>312 </div>
250 </div>313 </SidebarInstanceContext.Provider>
251 );314 );
252}315}
253316
...@@ -273,12 +336,244 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t...@@ -273,12 +336,244 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t
273 );336 );
274}337}
275338
276function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {339function clampSidebarWidth(width: number, options: SidebarResolvedResizableOptions): number {
277 const { toggleSidebar } = useSidebar();340 return Math.max(options.minWidth, Math.min(width, options.maxWidth));
341}
342
343function SidebarRail({
344 className,
345 onClick,
346 onPointerCancel,
347 onPointerDown,
348 onPointerMove,
349 onPointerUp,
350 ...props
351}: React.ComponentProps<"button">) {
352 const { open, toggleSidebar } = useSidebar();
353 const sidebarInstance = React.useContext(SidebarInstanceContext);
354 const railRef = React.useRef<HTMLButtonElement | null>(null);
355 const suppressClickRef = React.useRef(false);
356 const resizeStateRef = React.useRef<{
357 moved: boolean;
358 pointerId: number;
359 pendingWidth: number;
360 rail: HTMLButtonElement;
361 rafId: number | null;
362 sidebarRoot: HTMLElement;
363 side: "left" | "right";
364 startWidth: number;
365 startX: number;
366 transitionTargets: HTMLElement[];
367 width: number;
368 wrapper: HTMLElement;
369 } | null>(null);
370 const resolvedResizable = sidebarInstance?.resizable ?? null;
371 const canResize = resolvedResizable !== null && open;
372 const railLabel = canResize ? "Resize Sidebar" : "Toggle Sidebar";
373 const railTitle = canResize ? "Drag to resize sidebar" : "Toggle Sidebar";
374
375 const stopResize = React.useCallback(
376 (pointerId: number) => {
377 const resizeState = resizeStateRef.current;
378 if (!resizeState) {
379 return;
380 }
381 if (resizeState.rafId !== null) {
382 window.cancelAnimationFrame(resizeState.rafId);
383 }
384 resizeState.transitionTargets.forEach((element) => {
385 element.style.removeProperty("transition-duration");
386 });
387 if (resolvedResizable?.storageKey && typeof window !== "undefined") {
388 window.localStorage.setItem(resolvedResizable.storageKey, String(resizeState.width));
389 }
390 resolvedResizable?.onResize?.(resizeState.width);
391 resizeStateRef.current = null;
392 if (resizeState.rail.hasPointerCapture(pointerId)) {
393 resizeState.rail.releasePointerCapture(pointerId);
394 }
395 document.body.style.removeProperty("cursor");
396 document.body.style.removeProperty("user-select");
397 },
398 [resolvedResizable],
399 );
400
401 const handlePointerDown = React.useCallback(
402 (event: React.PointerEvent<HTMLButtonElement>) => {
403 onPointerDown?.(event);
404 if (event.defaultPrevented) return;
405 if (!resolvedResizable || !open || event.button !== 0) return;
406
407 const wrapper = event.currentTarget.closest<HTMLElement>("[data-slot='sidebar-wrapper']");
408 const sidebarRoot = event.currentTarget.closest<HTMLElement>("[data-slot='sidebar']");
409 if (!wrapper || !sidebarRoot) {
410 return;
411 }
412
413 const sidebarContainer = sidebarRoot.querySelector<HTMLElement>("[data-slot='sidebar-container']");
414 if (!sidebarContainer) {
415 return;
416 }
417
418 const startWidth = sidebarContainer.getBoundingClientRect().width;
419 const initialWidth = clampSidebarWidth(startWidth, resolvedResizable);
420 const transitionTargets = [
421 sidebarRoot.querySelector<HTMLElement>("[data-slot='sidebar-gap']"),
422 sidebarRoot.querySelector<HTMLElement>("[data-slot='sidebar-container']"),
423 ].filter((element): element is HTMLElement => element !== null);
424 transitionTargets.forEach((element) => {
425 element.style.setProperty("transition-duration", "0ms");
426 });
427
428 event.preventDefault();
429 event.stopPropagation();
430 resizeStateRef.current = {
431 moved: false,
432 pointerId: event.pointerId,
433 pendingWidth: initialWidth,
434 rail: event.currentTarget,
435 rafId: null,
436 sidebarRoot,
437 side: sidebarInstance?.side ?? "left",
438 startWidth: initialWidth,
439 startX: event.clientX,
440 transitionTargets,
441 width: initialWidth,
442 wrapper,
443 };
444 wrapper.style.setProperty("--sidebar-width", `${initialWidth}px`);
445 event.currentTarget.setPointerCapture(event.pointerId);
446 document.body.style.cursor = "col-resize";
447 document.body.style.userSelect = "none";
448 },
449 [onPointerDown, open, resolvedResizable, sidebarInstance?.side],
450 );
451
452 const handlePointerMove = React.useCallback(
453 (event: React.PointerEvent<HTMLButtonElement>) => {
454 onPointerMove?.(event);
455 if (event.defaultPrevented) return;
456 const resizeState = resizeStateRef.current;
457 if (!resizeState || resizeState.pointerId !== event.pointerId || !resolvedResizable) return;
458
459 event.preventDefault();
460 const delta =
461 resizeState.side === "right"
462 ? resizeState.startX - event.clientX
463 : event.clientX - resizeState.startX;
464 if (Math.abs(delta) > 2) {
465 resizeState.moved = true;
466 }
467 resizeState.pendingWidth = clampSidebarWidth(resizeState.startWidth + delta, resolvedResizable);
468 if (resizeState.rafId !== null) {
469 return;
470 }
471
472 resizeState.rafId = window.requestAnimationFrame(() => {
473 const activeResizeState = resizeStateRef.current;
474 if (!activeResizeState || !resolvedResizable) return;
475
476 activeResizeState.rafId = null;
477 const nextWidth = activeResizeState.pendingWidth;
478 const accepted =
479 resolvedResizable.shouldAcceptWidth?.({
480 currentWidth: activeResizeState.width,
481 nextWidth,
482 rail: activeResizeState.rail,
483 side: activeResizeState.side,
484 sidebarRoot: activeResizeState.sidebarRoot,
485 wrapper: activeResizeState.wrapper,
486 }) ?? true;
487 if (!accepted) {
488 return;
489 }
490
491 activeResizeState.wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`);
492 activeResizeState.width = nextWidth;
493 });
494 },
495 [onPointerMove, resolvedResizable],
496 );
497
498 const endResizeInteraction = React.useCallback(
499 (event: React.PointerEvent<HTMLButtonElement>) => {
500 const resizeState = resizeStateRef.current;
501 if (!resizeState || resizeState.pointerId !== event.pointerId) return;
502
503 event.preventDefault();
504 suppressClickRef.current = resizeState.moved;
505 stopResize(event.pointerId);
506 },
507 [stopResize],
508 );
509
510 const handlePointerUp = React.useCallback(
511 (event: React.PointerEvent<HTMLButtonElement>) => {
512 onPointerUp?.(event);
513 if (event.defaultPrevented) return;
514 endResizeInteraction(event);
515 },
516 [endResizeInteraction, onPointerUp],
517 );
518
519 const handlePointerCancel = React.useCallback(
520 (event: React.PointerEvent<HTMLButtonElement>) => {
521 onPointerCancel?.(event);
522 if (event.defaultPrevented) return;
523 endResizeInteraction(event);
524 },
525 [endResizeInteraction, onPointerCancel],
526 );
527
528 const handleClick = React.useCallback(
529 (event: React.MouseEvent<HTMLButtonElement>) => {
530 onClick?.(event);
531 if (event.defaultPrevented) return;
532 if (suppressClickRef.current) {
533 suppressClickRef.current = false;
534 event.preventDefault();
535 return;
536 }
537 if (resolvedResizable && open) {
538 event.preventDefault();
539 return;
540 }
541 toggleSidebar();
542 },
543 [onClick, open, resolvedResizable, toggleSidebar],
544 );
545
546 React.useEffect(() => {
547 if (!resolvedResizable?.storageKey || typeof window === "undefined") return;
548 const rail = railRef.current;
549 if (!rail) return;
550 const wrapper = rail.closest<HTMLElement>("[data-slot='sidebar-wrapper']");
551 if (!wrapper) return;
552
553 const storedWidth = Number(window.localStorage.getItem(resolvedResizable.storageKey));
554 if (!Number.isFinite(storedWidth)) return;
555 const clampedWidth = clampSidebarWidth(storedWidth, resolvedResizable);
556 wrapper.style.setProperty("--sidebar-width", `${clampedWidth}px`);
557 resolvedResizable.onResize?.(clampedWidth);
558 }, [resolvedResizable]);
559
560 React.useEffect(() => {
561 return () => {
562 const resizeState = resizeStateRef.current;
563 if (resizeState?.rafId != null) {
564 window.cancelAnimationFrame(resizeState.rafId);
565 }
566 resizeState?.transitionTargets.forEach((element) => {
567 element.style.removeProperty("transition-duration");
568 });
569 document.body.style.removeProperty("cursor");
570 document.body.style.removeProperty("user-select");
571 };
572 }, []);
278573
279 return (574 return (
280 <button575 <button
281 aria-label="Toggle Sidebar"576 aria-label={railLabel}
282 className={cn(577 className={cn(
283 "-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex",578 "-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex",
284 "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",579 "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
...@@ -290,9 +585,14 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {...@@ -290,9 +585,14 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
290 )}585 )}
291 data-sidebar="rail"586 data-sidebar="rail"
292 data-slot="sidebar-rail"587 data-slot="sidebar-rail"
293 onClick={toggleSidebar}588 onClick={handleClick}
589 onPointerCancel={handlePointerCancel}
590 onPointerDown={handlePointerDown}
591 onPointerMove={handlePointerMove}
592 onPointerUp={handlePointerUp}
593 ref={railRef}
294 tabIndex={-1}594 tabIndex={-1}
295 title="Toggle Sidebar"595 title={railTitle}
296 type="button"596 type="button"
297 {...props}597 {...props}
298 />598 />
apps/web/src/markdown-links.test.ts created+37
...@@ -0,0 +1,37 @@
1import { describe, expect, it } from "vitest";
2
3import { resolveMarkdownFileLinkTarget } from "./markdown-links";
4
5describe("resolveMarkdownFileLinkTarget", () => {
6 it("resolves absolute posix file paths", () => {
7 expect(resolveMarkdownFileLinkTarget("/Users/julius/project/AGENTS.md")).toBe(
8 "/Users/julius/project/AGENTS.md",
9 );
10 });
11
12 it("resolves relative file paths against cwd", () => {
13 expect(resolveMarkdownFileLinkTarget("src/processRunner.ts:71", "/Users/julius/project")).toBe(
14 "/Users/julius/project/src/processRunner.ts:71",
15 );
16 });
17
18 it("resolves bare file names against cwd", () => {
19 expect(resolveMarkdownFileLinkTarget("AGENTS.md", "/Users/julius/project")).toBe(
20 "/Users/julius/project/AGENTS.md",
21 );
22 });
23
24 it("maps #L line anchors to editor line suffixes", () => {
25 expect(
26 resolveMarkdownFileLinkTarget("/Users/julius/project/src/main.ts#L42C7"),
27 ).toBe("/Users/julius/project/src/main.ts:42:7");
28 });
29
30 it("ignores external urls", () => {
31 expect(resolveMarkdownFileLinkTarget("https://example.com/docs")).toBeNull();
32 });
33
34 it("does not treat app routes as file links", () => {
35 expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull();
36 });
37});
apps/web/src/markdown-links.ts created+126
...@@ -0,0 +1,126 @@
1import { resolvePathLinkTarget } from "./terminal-links";
2
3const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
4const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;
5const EXTERNAL_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/;
6const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
7const RELATIVE_FILE_PATH_PATTERN =
8 /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/;
9const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
10const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
11const POSIX_FILE_ROOT_PREFIXES = [
12 "/Users/",
13 "/home/",
14 "/tmp/",
15 "/var/",
16 "/etc/",
17 "/opt/",
18 "/mnt/",
19 "/Volumes/",
20 "/private/",
21 "/root/",
22] as const;
23
24function safeDecode(value: string): string {
25 try {
26 return decodeURIComponent(value);
27 } catch {
28 return value;
29 }
30}
31
32function stripSearchAndHash(value: string): { path: string; hash: string } {
33 const hashIndex = value.indexOf("#");
34 const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value;
35 const rawHash = hashIndex >= 0 ? value.slice(hashIndex) : "";
36 const queryIndex = pathWithSearch.indexOf("?");
37 const path = queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch;
38 return { path, hash: rawHash };
39}
40
41function parseFileUrlHref(href: string): { path: string; hash: string } | null {
42 try {
43 const parsed = new URL(href);
44 if (parsed.protocol.toLowerCase() !== "file:") return null;
45
46 const decodedPath = safeDecode(parsed.pathname);
47 if (decodedPath.length === 0) return null;
48
49 // Browser URL parser encodes "C:/foo" as "/C:/foo" for file URLs.
50 const normalizedPath = /^\/[A-Za-z]:[\\/]/.test(decodedPath)
51 ? decodedPath.slice(1)
52 : decodedPath;
53
54 return { path: normalizedPath, hash: parsed.hash };
55 } catch {
56 return null;
57 }
58}
59
60function looksLikePosixFilesystemPath(path: string): boolean {
61 if (!path.startsWith("/")) return false;
62 if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true;
63 if (POSITION_SUFFIX_PATTERN.test(path)) return true;
64 const basename = path.slice(path.lastIndexOf("/") + 1);
65 return /\.[A-Za-z0-9_-]+$/.test(basename);
66}
67
68function appendLineColumnFromHash(path: string, hash: string): string {
69 if (!hash || POSITION_SUFFIX_PATTERN.test(path)) return path;
70 const match = hash.match(/^#L(\d+)(?:C(\d+))?$/i);
71 if (!match?.[1]) return path;
72 const line = match[1];
73 const column = match[2];
74 return `${path}:${line}${column ? `:${column}` : ""}`;
75}
76
77function isLikelyPathCandidate(path: string): boolean {
78 if (WINDOWS_DRIVE_PATH_PATTERN.test(path) || WINDOWS_UNC_PATH_PATTERN.test(path)) return true;
79 if (RELATIVE_PATH_PREFIX_PATTERN.test(path)) return true;
80 if (path.startsWith("/")) return looksLikePosixFilesystemPath(path);
81 return RELATIVE_FILE_PATH_PATTERN.test(path) || RELATIVE_FILE_NAME_PATTERN.test(path);
82}
83
84function isRelativePath(path: string): boolean {
85 return (
86 RELATIVE_PATH_PREFIX_PATTERN.test(path) ||
87 (!path.startsWith("/") &&
88 !WINDOWS_DRIVE_PATH_PATTERN.test(path) &&
89 !WINDOWS_UNC_PATH_PATTERN.test(path))
90 );
91}
92
93export function resolveMarkdownFileLinkTarget(
94 href: string | undefined,
95 cwd?: string,
96): string | null {
97 if (!href) return null;
98 const rawHref = href.trim();
99 if (rawHref.length === 0 || rawHref.startsWith("#")) return null;
100
101 const fileUrlTarget = rawHref.toLowerCase().startsWith("file:")
102 ? parseFileUrlHref(rawHref)
103 : null;
104 const source = fileUrlTarget ?? stripSearchAndHash(rawHref);
105 const decodedPath = safeDecode(source.path.trim());
106 const decodedHash = safeDecode(source.hash.trim());
107
108 if (decodedPath.length === 0) return null;
109 if (
110 !WINDOWS_DRIVE_PATH_PATTERN.test(decodedPath) &&
111 !WINDOWS_UNC_PATH_PATTERN.test(decodedPath) &&
112 EXTERNAL_SCHEME_PATTERN.test(decodedPath)
113 ) {
114 return null;
115 }
116
117 if (!isLikelyPathCandidate(decodedPath)) return null;
118
119 const pathWithPosition = appendLineColumnFromHash(decodedPath, decodedHash);
120 if (!isRelativePath(pathWithPosition)) {
121 return pathWithPosition;
122 }
123
124 if (!cwd) return null;
125 return resolvePathLinkTarget(pathWithPosition, cwd);
126}
apps/web/src/routes/_chat.$threadId.tsx+133-36
...@@ -6,45 +6,130 @@ import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"...@@ -6,45 +6,130 @@ import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"
6import { useMediaQuery } from "../hooks/useMediaQuery";6import { useMediaQuery } from "../hooks/useMediaQuery";
7import { useStore } from "../store";7import { useStore } from "../store";
8import { Sheet, SheetPopup } from "../components/ui/sheet";8import { Sheet, SheetPopup } from "../components/ui/sheet";
9import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar";
910
10const DiffPanel = lazy(() => import("../components/DiffPanel"));11const DiffPanel = lazy(() => import("../components/DiffPanel"));
11const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";12const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";
13const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width";
14const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)";
15const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 28 * 16;
1216
13const DiffPanelWrapper = (props: {17const DiffPanelSheet = (props: {
14 children: ReactNode;18 children: ReactNode;
15 sheet: boolean;
16 diffOpen: boolean;19 diffOpen: boolean;
17 onCloseDiff: () => void;20 onCloseDiff: () => void;
18}) => {21}) => {
19 if (props.sheet) {22 return (
20 return (23 <Sheet
21 <Sheet24 open={props.diffOpen}
22 open={props.diffOpen}25 onOpenChange={(open) => {
23 onOpenChange={(open) => {26 if (!open) {
24 if (!open) {27 props.onCloseDiff();
25 props.onCloseDiff();28 }
26 }29 }}
27 }}30 >
31 <SheetPopup
32 side="right"
33 showCloseButton={false}
34 keepMounted
35 className="w-[min(88vw,820px)] max-w-[820px] p-0"
28 >36 >
29 <SheetPopup37 {props.children}
30 side="right"38 </SheetPopup>
31 showCloseButton={false}39 </Sheet>
32 keepMounted40 );
33 className="w-[min(88vw,820px)] max-w-[820px] p-0"41};
34 >42
35 {props.children}43const DiffLoadingFallback = (props: { inline: boolean }) => {
36 </SheetPopup>44 if (props.inline) {
37 </Sheet>45 return (
46 <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70">
47 Loading diff viewer...
48 </div>
38 );49 );
39 }50 }
4051
41 return (52 return (
42 <aside className={props.diffOpen ? undefined : "hidden"} aria-hidden={!props.diffOpen}>53 <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">
43 {props.children}54 Loading diff viewer...
44 </aside>55 </aside>
45 );56 );
46};57};
4758
59const DiffPanelInlineSidebar = (props: {
60 diffOpen: boolean;
61 onCloseDiff: () => void;
62 onOpenDiff: () => void;
63}) => {
64 const { diffOpen, onCloseDiff, onOpenDiff } = props;
65 const onOpenChange = useCallback(
66 (open: boolean) => {
67 if (open) {
68 onOpenDiff();
69 return;
70 }
71 onCloseDiff();
72 },
73 [onCloseDiff, onOpenDiff],
74 );
75 const shouldAcceptInlineSidebarWidth = useCallback(
76 ({ nextWidth, wrapper }: { nextWidth: number; wrapper: HTMLElement }) => {
77 const composerForm = document.querySelector<HTMLElement>("[data-chat-composer-form='true']");
78 if (!composerForm) return true;
79 const composerViewport = composerForm.parentElement;
80 if (!composerViewport) return true;
81 const previousSidebarWidth = wrapper.style.getPropertyValue("--sidebar-width");
82 wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`);
83
84 const viewportStyle = window.getComputedStyle(composerViewport);
85 const viewportPaddingLeft = Number.parseFloat(viewportStyle.paddingLeft) || 0;
86 const viewportPaddingRight = Number.parseFloat(viewportStyle.paddingRight) || 0;
87 const viewportContentWidth = Math.max(
88 0,
89 composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight,
90 );
91 const formRect = composerForm.getBoundingClientRect();
92 const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5;
93 const overflowsViewport = formRect.width > viewportContentWidth + 0.5;
94
95 if (previousSidebarWidth.length > 0) {
96 wrapper.style.setProperty("--sidebar-width", previousSidebarWidth);
97 } else {
98 wrapper.style.removeProperty("--sidebar-width");
99 }
100
101 return !hasComposerOverflow && !overflowsViewport;
102 },
103 [],
104 );
105
106 return (
107 <SidebarProvider
108 defaultOpen={false}
109 open={diffOpen}
110 onOpenChange={onOpenChange}
111 className="w-auto min-h-0 flex-none bg-transparent"
112 style={{ "--sidebar-width": DIFF_INLINE_DEFAULT_WIDTH } as React.CSSProperties}
113 >
114 <Sidebar
115 side="right"
116 collapsible="offcanvas"
117 className="border-l border-border bg-card text-foreground"
118 resizable={{
119 minWidth: DIFF_INLINE_SIDEBAR_MIN_WIDTH,
120 shouldAcceptWidth: shouldAcceptInlineSidebarWidth,
121 storageKey: DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY,
122 }}
123 >
124 <Suspense fallback={<DiffLoadingFallback inline />}>
125 <DiffPanel mode="sidebar" />
126 </Suspense>
127 <SidebarRail />
128 </Sidebar>
129 </SidebarProvider>
130 );
131};
132
48function ChatThreadRouteView() {133function ChatThreadRouteView() {
49 const { state } = useStore();134 const { state } = useStore();
50 const navigate = useNavigate();135 const navigate = useNavigate();
...@@ -62,6 +147,16 @@ function ChatThreadRouteView() {...@@ -62,6 +147,16 @@ function ChatThreadRouteView() {
62 },147 },
63 });148 });
64 }, [navigate, threadId]);149 }, [navigate, threadId]);
150 const openDiff = useCallback(() => {
151 void navigate({
152 to: "/$threadId",
153 params: { threadId },
154 search: (previous) => {
155 const rest = stripDiffSearchParams(previous);
156 return { ...rest, diff: "1" };
157 },
158 });
159 }, [navigate, threadId]);
65160
66 useEffect(() => {161 useEffect(() => {
67 if (!threadId) {162 if (!threadId) {
...@@ -83,25 +178,27 @@ function ChatThreadRouteView() {...@@ -83,25 +178,27 @@ function ChatThreadRouteView() {
83 return null;178 return null;
84 }179 }
85180
86 const diffLoadingFallback =181 if (!shouldUseDiffSheet) {
87 !diffOpen || shouldUseDiffSheet ? (182 return (
88 <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70">183 <>
89 Loading diff viewer...184 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
90 </div>185 <ChatView threadId={threadId} />
91 ) : (186 </SidebarInset>
92 <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">187 <DiffPanelInlineSidebar diffOpen={diffOpen} onCloseDiff={closeDiff} onOpenDiff={openDiff} />
93 Loading diff viewer...188 </>
94 </aside>
95 );189 );
190 }
96191
97 return (192 return (
98 <>193 <>
99 <ChatView threadId={threadId} />194 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
100 <DiffPanelWrapper sheet={shouldUseDiffSheet} diffOpen={diffOpen} onCloseDiff={closeDiff}>195 <ChatView threadId={threadId} />
101 <Suspense fallback={diffLoadingFallback}>196 </SidebarInset>
102 <DiffPanel mode={shouldUseDiffSheet ? "sheet" : "inline"} />197 <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}>
198 <Suspense fallback={<DiffLoadingFallback inline={false} />}>
199 <DiffPanel mode="sheet" />
103 </Suspense>200 </Suspense>
104 </DiffPanelWrapper>201 </DiffPanelSheet>
105 </>202 </>
106 );203 );
107}204}
apps/web/src/routes/_chat.settings.tsx+186-180
...@@ -7,6 +7,7 @@ import { useStore } from "../store";...@@ -7,6 +7,7 @@ import { useStore } from "../store";
7import { Button } from "../components/ui/button";7import { Button } from "../components/ui/button";
8import { Input } from "../components/ui/input";8import { Input } from "../components/ui/input";
9import { Switch } from "../components/ui/switch";9import { Switch } from "../components/ui/switch";
10import { SidebarInset } from "~/components/ui/sidebar";
1011
11const THEME_OPTIONS = [12const THEME_OPTIONS = [
12 {13 {
...@@ -48,209 +49,214 @@ function SettingsRouteView() {...@@ -48,209 +49,214 @@ function SettingsRouteView() {
48 const codexHomePath = settings.codexHomePath;49 const codexHomePath = settings.codexHomePath;
4950
50 return (51 return (
51 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">52 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
52 {isElectron && (53 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
53 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">54 {isElectron && (
54 <span className="text-xs font-medium tracking-wide text-muted-foreground/70">Settings</span>55 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
55 </div>56 <span className="text-xs font-medium tracking-wide text-muted-foreground/70">
56 )}57 Settings
5758 </span>
58 <div className="flex-1 overflow-y-auto p-6">59 </div>
59 <div className="mx-auto flex w-full max-w-3xl flex-col gap-6">60 )}
60 <header className="space-y-1">
61 <h1 className="text-2xl font-semibold tracking-tight text-foreground">Settings</h1>
62 <p className="text-sm text-muted-foreground">
63 Configure app-level preferences for this device.
64 </p>
65 </header>
6661
67 <section className="rounded-2xl border border-border bg-card p-5">62 <div className="flex-1 overflow-y-auto p-6">
68 <div className="mb-4">63 <div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
69 <h2 className="text-sm font-medium text-foreground">Appearance</h2>64 <header className="space-y-1">
70 <p className="mt-1 text-xs text-muted-foreground">65 <h1 className="text-2xl font-semibold tracking-tight text-foreground">Settings</h1>
71 Choose how T3 Code handles light and dark mode.66 <p className="text-sm text-muted-foreground">
67 Configure app-level preferences for this device.
72 </p>68 </p>
73 </div>69 </header>
7470
75 <div className="space-y-2" role="radiogroup" aria-label="Theme preference">71 <section className="rounded-2xl border border-border bg-card p-5">
76 {THEME_OPTIONS.map((option) => {72 <div className="mb-4">
77 const selected = theme === option.value;73 <h2 className="text-sm font-medium text-foreground">Appearance</h2>
78 return (74 <p className="mt-1 text-xs text-muted-foreground">
79 <button75 Choose how T3 Code handles light and dark mode.
80 key={option.value}76 </p>
81 type="button"77 </div>
82 role="radio"
83 aria-checked={selected}
84 className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${
85 selected
86 ? "border-primary/60 bg-primary/8 text-foreground"
87 : "border-border bg-background text-muted-foreground hover:bg-accent"
88 }`}
89 onClick={() => setTheme(option.value)}
90 >
91 <span className="flex flex-col">
92 <span className="text-sm font-medium">{option.label}</span>
93 <span className="text-xs">{option.description}</span>
94 </span>
95 {selected ? (
96 <span className="rounded bg-primary/14 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary">
97 Selected
98 </span>
99 ) : null}
100 </button>
101 );
102 })}
103 </div>
10478
105 <p className="mt-4 text-xs text-muted-foreground">79 <div className="space-y-2" role="radiogroup" aria-label="Theme preference">
106 Active theme: <span className="font-medium text-foreground">{resolvedTheme}</span>80 {THEME_OPTIONS.map((option) => {
107 </p>81 const selected = theme === option.value;
108 </section>82 return (
83 <button
84 key={option.value}
85 type="button"
86 role="radio"
87 aria-checked={selected}
88 className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${
89 selected
90 ? "border-primary/60 bg-primary/8 text-foreground"
91 : "border-border bg-background text-muted-foreground hover:bg-accent"
92 }`}
93 onClick={() => setTheme(option.value)}
94 >
95 <span className="flex flex-col">
96 <span className="text-sm font-medium">{option.label}</span>
97 <span className="text-xs">{option.description}</span>
98 </span>
99 {selected ? (
100 <span className="rounded bg-primary/14 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary">
101 Selected
102 </span>
103 ) : null}
104 </button>
105 );
106 })}
107 </div>
109108
110 <section className="rounded-2xl border border-border bg-card p-5">109 <p className="mt-4 text-xs text-muted-foreground">
111 <div className="mb-4">110 Active theme: <span className="font-medium text-foreground">{resolvedTheme}</span>
112 <h2 className="text-sm font-medium text-foreground">Codex App Server</h2>
113 <p className="mt-1 text-xs text-muted-foreground">
114 These overrides apply to new sessions and let you use a non-default Codex install.
115 </p>111 </p>
116 </div>112 </section>
117
118 <div className="space-y-4">
119 <label className="block space-y-1">
120 <span className="text-xs font-medium text-foreground">Codex binary path</span>
121 <Input
122 value={codexBinaryPath}
123 onChange={(event) => updateSettings({ codexBinaryPath: event.target.value })}
124 placeholder="codex"
125 spellCheck={false}
126 />
127 <span className="text-xs text-muted-foreground">
128 Leave blank to use <code>codex</code> from your PATH.
129 </span>
130 </label>
131113
132 <label className="block space-y-1">114 <section className="rounded-2xl border border-border bg-card p-5">
133 <span className="text-xs font-medium text-foreground">CODEX_HOME path</span>115 <div className="mb-4">
134 <Input116 <h2 className="text-sm font-medium text-foreground">Codex App Server</h2>
135 value={codexHomePath}117 <p className="mt-1 text-xs text-muted-foreground">
136 onChange={(event) => updateSettings({ codexHomePath: event.target.value })}118 These overrides apply to new sessions and let you use a non-default Codex install.
137 placeholder="/Users/you/.codex"
138 spellCheck={false}
139 />
140 <span className="text-xs text-muted-foreground">
141 Optional custom Codex home/config directory.
142 </span>
143 </label>
144
145 <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
146 <p>
147 Binary source: <span className="font-medium text-foreground">{codexBinaryPath || "PATH"}</span>
148 </p>119 </p>
149 <Button
150 size="xs"
151 variant="outline"
152 onClick={() =>
153 updateSettings({
154 codexBinaryPath: defaults.codexBinaryPath,
155 codexHomePath: defaults.codexHomePath,
156 })
157 }
158 >
159 Reset codex overrides
160 </Button>
161 </div>120 </div>
162 </div>
163 </section>
164121
165 <section className="rounded-2xl border border-border bg-card p-5">122 <div className="space-y-4">
166 <div className="mb-4">123 <label className="block space-y-1">
167 <h2 className="text-sm font-medium text-foreground">Runtime Mode</h2>124 <span className="text-xs font-medium text-foreground">Codex binary path</span>
168 <p className="mt-1 text-xs text-muted-foreground">125 <Input
169 Select the default execution policy for this client.126 value={codexBinaryPath}
170 </p>127 onChange={(event) => updateSettings({ codexBinaryPath: event.target.value })}
171 </div>128 placeholder="codex"
129 spellCheck={false}
130 />
131 <span className="text-xs text-muted-foreground">
132 Leave blank to use <code>codex</code> from your PATH.
133 </span>
134 </label>
172135
173 <div className="space-y-2" role="radiogroup" aria-label="Runtime mode preference">136 <label className="block space-y-1">
174 {RUNTIME_MODE_OPTIONS.map((option) => {137 <span className="text-xs font-medium text-foreground">CODEX_HOME path</span>
175 const selected = state.runtimeMode === option.value;138 <Input
176 return (139 value={codexHomePath}
177 <button140 onChange={(event) => updateSettings({ codexHomePath: event.target.value })}
178 key={option.value}141 placeholder="/Users/you/.codex"
179 type="button"142 spellCheck={false}
180 role="radio"143 />
181 aria-checked={selected}144 <span className="text-xs text-muted-foreground">
182 className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${145 Optional custom Codex home/config directory.
183 selected146 </span>
184 ? "border-primary/60 bg-primary/8 text-foreground"147 </label>
185 : "border-border bg-background text-muted-foreground hover:bg-accent"148
186 }`}149 <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
187 onClick={() => {150 <p>
188 dispatch({151 Binary source:{" "}
189 type: "SET_RUNTIME_MODE",152 <span className="font-medium text-foreground">{codexBinaryPath || "PATH"}</span>
190 mode: option.value,153 </p>
191 });154 <Button
192 }}155 size="xs"
156 variant="outline"
157 onClick={() =>
158 updateSettings({
159 codexBinaryPath: defaults.codexBinaryPath,
160 codexHomePath: defaults.codexHomePath,
161 })
162 }
193 >163 >
194 <span className="flex flex-col">164 Reset codex overrides
195 <span className="text-sm font-medium">{option.label}</span>165 </Button>
196 <span className="text-xs">{option.description}</span>166 </div>
197 </span>167 </div>
198 {selected ? (168 </section>
199 <span className="rounded bg-primary/14 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary">
200 Selected
201 </span>
202 ) : null}
203 </button>
204 );
205 })}
206 </div>
207 </section>
208169
209 <section className="rounded-2xl border border-border bg-card p-5">170 <section className="rounded-2xl border border-border bg-card p-5">
210 <div className="mb-4">171 <div className="mb-4">
211 <h2 className="text-sm font-medium text-foreground">Safety</h2>172 <h2 className="text-sm font-medium text-foreground">Runtime Mode</h2>
212 <p className="mt-1 text-xs text-muted-foreground">173 <p className="mt-1 text-xs text-muted-foreground">
213 Additional guardrails for destructive local actions.174 Select the default execution policy for this client.
214 </p>175 </p>
215 </div>176 </div>
177
178 <div className="space-y-2" role="radiogroup" aria-label="Runtime mode preference">
179 {RUNTIME_MODE_OPTIONS.map((option) => {
180 const selected = state.runtimeMode === option.value;
181 return (
182 <button
183 key={option.value}
184 type="button"
185 role="radio"
186 aria-checked={selected}
187 className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${
188 selected
189 ? "border-primary/60 bg-primary/8 text-foreground"
190 : "border-border bg-background text-muted-foreground hover:bg-accent"
191 }`}
192 onClick={() => {
193 dispatch({
194 type: "SET_RUNTIME_MODE",
195 mode: option.value,
196 });
197 }}
198 >
199 <span className="flex flex-col">
200 <span className="text-sm font-medium">{option.label}</span>
201 <span className="text-xs">{option.description}</span>
202 </span>
203 {selected ? (
204 <span className="rounded bg-primary/14 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary">
205 Selected
206 </span>
207 ) : null}
208 </button>
209 );
210 })}
211 </div>
212 </section>
216213
217 <div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2">214 <section className="rounded-2xl border border-border bg-card p-5">
218 <div>215 <div className="mb-4">
219 <p className="text-sm font-medium text-foreground">Confirm thread deletion</p>216 <h2 className="text-sm font-medium text-foreground">Safety</h2>
220 <p className="text-xs text-muted-foreground">217 <p className="mt-1 text-xs text-muted-foreground">
221 Ask for confirmation before deleting a thread and its chat history.218 Additional guardrails for destructive local actions.
222 </p>219 </p>
223 </div>220 </div>
224 <Switch
225 checked={settings.confirmThreadDelete}
226 onCheckedChange={(checked) =>
227 updateSettings({
228 confirmThreadDelete: Boolean(checked),
229 })
230 }
231 aria-label="Confirm thread deletion"
232 />
233 </div>
234221
235 {settings.confirmThreadDelete !== defaults.confirmThreadDelete ? (222 <div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2">
236 <div className="mt-3 flex justify-end">223 <div>
237 <Button224 <p className="text-sm font-medium text-foreground">Confirm thread deletion</p>
238 size="xs"225 <p className="text-xs text-muted-foreground">
239 variant="outline"226 Ask for confirmation before deleting a thread and its chat history.
240 onClick={() =>227 </p>
228 </div>
229 <Switch
230 checked={settings.confirmThreadDelete}
231 onCheckedChange={(checked) =>
241 updateSettings({232 updateSettings({
242 confirmThreadDelete: defaults.confirmThreadDelete,233 confirmThreadDelete: Boolean(checked),
243 })234 })
244 }235 }
245 >236 aria-label="Confirm thread deletion"
246 Restore default237 />
247 </Button>
248 </div>238 </div>
249 ) : null}239
250 </section>240 {settings.confirmThreadDelete !== defaults.confirmThreadDelete ? (
241 <div className="mt-3 flex justify-end">
242 <Button
243 size="xs"
244 variant="outline"
245 onClick={() =>
246 updateSettings({
247 confirmThreadDelete: defaults.confirmThreadDelete,
248 })
249 }
250 >
251 Restore default
252 </Button>
253 </div>
254 ) : null}
255 </section>
256 </div>
251 </div>257 </div>
252 </div>258 </div>
253 </div>259 </SidebarInset>
254 );260 );
255}261}
256262
apps/web/src/routes/_chat.tsx+3-5
...@@ -33,11 +33,9 @@ function ChatRouteLayout() {...@@ -33,11 +33,9 @@ function ChatRouteLayout() {
33 >33 >
34 <ThreadSidebar />34 <ThreadSidebar />
35 </Sidebar>35 </Sidebar>
36 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">36 <DiffWorkerPoolProvider>
37 <DiffWorkerPoolProvider>37 <Outlet />
38 <Outlet />38 </DiffWorkerPoolProvider>
39 </DiffWorkerPoolProvider>
40 </SidebarInset>
41 </SidebarProvider>39 </SidebarProvider>
42 );40 );
43}41}
apps/web/src/store.ts+113-121
...@@ -8,10 +8,7 @@ import {...@@ -8,10 +8,7 @@ import {
8 useReducer,8 useReducer,
9} from "react";9} from "react";
1010
11import {11import { type OrchestrationReadModel, type OrchestrationSessionStatus } from "@t3tools/contracts";
12 type OrchestrationReadModel,
13 type OrchestrationSessionStatus,
14} from "@t3tools/contracts";
15import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";12import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
16import {13import {
17 DEFAULT_THREAD_TERMINAL_HEIGHT,14 DEFAULT_THREAD_TERMINAL_HEIGHT,
...@@ -30,8 +27,7 @@ import {...@@ -30,8 +27,7 @@ import {
3027
31type Action =28type Action =
32 | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel }29 | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel }
33 | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] }30 | { type: "MARK_THREAD_VISITED"; threadId: string; visitedAt?: string }
34 | { type: "SET_THREADS_HYDRATED"; hydrated: boolean }
35 | { type: "TOGGLE_PROJECT"; projectId: string }31 | { type: "TOGGLE_PROJECT"; projectId: string }
36 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string }32 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string }
37 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean }33 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean }
...@@ -137,10 +133,6 @@ function updateThread(...@@ -137,10 +133,6 @@ function updateThread(
137 return threads.map((t) => (t.id === threadId ? updater(t) : t));133 return threads.map((t) => (t.id === threadId ? updater(t) : t));
138}134}
139135
140function cloneProjectScripts(scripts: ReadonlyArray<ProjectScript>): ProjectScript[] {
141 return scripts.map((script) => ({ ...script }));
142}
143
144function mapProjectsFromReadModel(136function mapProjectsFromReadModel(
145 incoming: OrchestrationReadModel["projects"],137 incoming: OrchestrationReadModel["projects"],
146 previous: Project[],138 previous: Project[],
...@@ -159,7 +151,7 @@ function mapProjectsFromReadModel(...@@ -159,7 +151,7 @@ function mapProjectsFromReadModel(
159 (persistedExpandedProjectCwds.size > 0151 (persistedExpandedProjectCwds.size > 0
160 ? persistedExpandedProjectCwds.has(project.workspaceRoot)152 ? persistedExpandedProjectCwds.has(project.workspaceRoot)
161 : true),153 : true),
162 scripts: cloneProjectScripts(project.scripts),154 scripts: project.scripts.map((script) => ({ ...script })),
163 };155 };
164 });156 });
165}157}
...@@ -187,7 +179,6 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" {...@@ -187,7 +179,6 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" {
187 return providerName === "claudeCode" ? "claudeCode" : "codex";179 return providerName === "claudeCode" ? "claudeCode" : "codex";
188}180}
189181
190
191function normalizeTerminalIds(terminalIds: string[]): string[] {182function normalizeTerminalIds(terminalIds: string[]): string[] {
192 const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0);183 const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0);
193 const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT);184 const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT);
...@@ -208,7 +199,6 @@ function normalizeRunningTerminalIds(...@@ -208,7 +199,6 @@ function normalizeRunningTerminalIds(
208 .filter((id) => id.length > 0 && validTerminalIdSet.has(id));199 .filter((id) => id.length > 0 && validTerminalIdSet.has(id));
209}200}
210201
211
212function normalizeTerminalGroupIds(terminalIds: string[]): string[] {202function normalizeTerminalGroupIds(terminalIds: string[]): string[] {
213 return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];203 return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
214}204}
...@@ -374,7 +364,6 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread {...@@ -374,7 +364,6 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread {
374 });364 });
375}365}
376366
377
378// ── Reducer ──────────────────────────────────────────────────────────367// ── Reducer ──────────────────────────────────────────────────────────
379368
380export function reducer(state: AppState, action: Action): AppState {369export function reducer(state: AppState, action: Action): AppState {
...@@ -384,94 +373,100 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -384,94 +373,100 @@ export function reducer(state: AppState, action: Action): AppState {
384 action.readModel.projects.filter((project) => project.deletedAt === null),373 action.readModel.projects.filter((project) => project.deletedAt === null),
385 state.projects,374 state.projects,
386 );375 );
387 const existingThreadById = new Map(state.threads.map((thread) => [thread.id, thread] as const));376 const existingThreadById = new Map(
377 state.threads.map((thread) => [thread.id, thread] as const),
378 );
388 const threads = action.readModel.threads379 const threads = action.readModel.threads
389 .filter((thread) => thread.deletedAt === null)380 .filter((thread) => thread.deletedAt === null)
390 .map((thread) => {381 .map((thread) => {
391 const existing = existingThreadById.get(thread.id);382 const existing = existingThreadById.get(thread.id);
392 const latestTurnStartedAt =383 const latestTurnStartedAt =
393 thread.latestTurnId === null384 thread.latestTurnId === null
394 ? undefined385 ? undefined
395 : thread.messages.find(386 : thread.messages.find(
396 (message) => message.turnId === thread.latestTurnId && message.role === "user",387 (message) => message.turnId === thread.latestTurnId && message.role === "user",
397 )?.createdAt;388 )?.createdAt;
398 const latestTurnCompletedAt = thread.checkpoints.find(389 const latestTurnCompletedAt = thread.checkpoints.find(
399 (checkpoint) => checkpoint.turnId === thread.latestTurnId,390 (checkpoint) => checkpoint.turnId === thread.latestTurnId,
400 )?.completedAt;391 )?.completedAt;
401 const latestTurnDurationMs =392 const latestTurnDurationMs =
402 latestTurnStartedAt && latestTurnCompletedAt393 latestTurnStartedAt && latestTurnCompletedAt
403 ? Math.max(0, Date.parse(latestTurnCompletedAt) - Date.parse(latestTurnStartedAt))394 ? Math.max(0, Date.parse(latestTurnCompletedAt) - Date.parse(latestTurnStartedAt))
404 : undefined;395 : undefined;
405396
406 return normalizeThreadTerminals({397 return normalizeThreadTerminals({
407 id: thread.id,398 id: thread.id,
408 codexThreadId: thread.session?.providerThreadId ?? null,399 codexThreadId: thread.session?.providerThreadId ?? null,
409 projectId: thread.projectId,400 projectId: thread.projectId,
410 title: thread.title,401 title: thread.title,
411 model: resolveModelSlug(thread.model),402 model: resolveModelSlug(thread.model),
412 terminalOpen: existing?.terminalOpen ?? false,403 terminalOpen: existing?.terminalOpen ?? false,
413 terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,404 terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,
414 terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID],405 terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID],
415 runningTerminalIds: existing?.runningTerminalIds ?? [],406 runningTerminalIds: existing?.runningTerminalIds ?? [],
416 activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID,407 activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID,
417 terminalGroups: existing?.terminalGroups ?? [408 terminalGroups: existing?.terminalGroups ?? [
418 { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] },409 {
419 ],410 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
420 activeTerminalGroupId: existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`,411 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
421 session: thread.session412 },
422 ? {413 ],
423 sessionId: thread.session.providerSessionId ?? `thread:${thread.id}`,414 activeTerminalGroupId:
424 provider: toLegacyProvider(thread.session.providerName),415 existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`,
425 status: toLegacySessionStatus(thread.session.status),416 session: thread.session
426 orchestrationStatus: thread.session.status,417 ? {
427 threadId: thread.session.providerThreadId,418 sessionId: thread.session.providerSessionId ?? `thread:${thread.id}`,
428 activeTurnId: thread.session.activeTurnId ?? undefined,419 provider: toLegacyProvider(thread.session.providerName),
429 createdAt: thread.session.updatedAt,420 status: toLegacySessionStatus(thread.session.status),
430 updatedAt: thread.session.updatedAt,421 orchestrationStatus: thread.session.status,
431 ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}),422 threadId: thread.session.providerThreadId,
432 }423 activeTurnId: thread.session.activeTurnId ?? undefined,
433 : null,424 createdAt: thread.session.updatedAt,
434 messages: thread.messages.map((message) => {425 updatedAt: thread.session.updatedAt,
435 const attachments = message.attachments?.map((attachment, index) => ({426 ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}),
436 type: "image" as const,427 }
437 id: `${message.id}:${index}`,428 : null,
438 name: attachment.name,429 messages: thread.messages.map((message) => {
439 mimeType: attachment.mimeType,430 const attachments = message.attachments?.map((attachment, index) => ({
440 sizeBytes: attachment.sizeBytes,431 type: "image" as const,
441 previewUrl: attachment.dataUrl,432 id: `${message.id}:${index}`,
442 }));433 name: attachment.name,
443 const normalizedMessage: ChatMessage = {434 mimeType: attachment.mimeType,
444 id: message.id,435 sizeBytes: attachment.sizeBytes,
445 role: message.role,436 previewUrl: attachment.dataUrl,
446 text: message.text,437 }));
447 createdAt: message.createdAt,438 const normalizedMessage: ChatMessage = {
448 streaming: message.streaming,439 id: message.id,
449 ...(message.streaming ? {} : { completedAt: message.updatedAt }),440 role: message.role,
450 ...(attachments && attachments.length > 0 ? { attachments } : {}),441 text: message.text,
451 };442 createdAt: message.createdAt,
452 return normalizedMessage;443 streaming: message.streaming,
453 }),444 ...(message.streaming ? {} : { completedAt: message.updatedAt }),
454 error: thread.session?.lastError ?? null,445 ...(attachments && attachments.length > 0 ? { attachments } : {}),
455 createdAt: thread.createdAt,446 };
456 latestTurnId: thread.latestTurnId ?? undefined,447 return normalizedMessage;
457 latestTurnStartedAt,448 }),
458 latestTurnCompletedAt,449 error: thread.session?.lastError ?? null,
459 latestTurnDurationMs,450 createdAt: thread.createdAt,
460 lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt,451 latestTurnId: thread.latestTurnId ?? undefined,
461 branch: thread.branch,452 latestTurnStartedAt,
462 worktreePath: thread.worktreePath,453 latestTurnCompletedAt,
463 turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({454 latestTurnDurationMs,
464 turnId: checkpoint.turnId,455 lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt,
465 completedAt: checkpoint.completedAt,456 branch: thread.branch,
466 status: checkpoint.status,457 worktreePath: thread.worktreePath,
467 assistantMessageId: checkpoint.assistantMessageId ?? undefined,458 turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({
468 checkpointTurnCount: checkpoint.checkpointTurnCount,459 turnId: checkpoint.turnId,
469 checkpointRef: checkpoint.checkpointRef,460 completedAt: checkpoint.completedAt,
470 files: checkpoint.files.map((file) => ({ ...file })),461 status: checkpoint.status,
471 })),462 assistantMessageId: checkpoint.assistantMessageId ?? undefined,
472 activities: thread.activities.map((activity) => ({ ...activity })),463 checkpointTurnCount: checkpoint.checkpointTurnCount,
464 checkpointRef: checkpoint.checkpointRef,
465 files: checkpoint.files.map((file) => ({ ...file })),
466 })),
467 activities: thread.activities.map((activity) => ({ ...activity })),
468 });
473 });469 });
474 });
475 return {470 return {
476 ...state,471 ...state,
477 projects,472 projects,
...@@ -480,24 +475,27 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -480,24 +475,27 @@ export function reducer(state: AppState, action: Action): AppState {
480 };475 };
481 }476 }
482477
483 case "SET_PROJECT_SCRIPTS":478 case "MARK_THREAD_VISITED": {
484 return {479 const visitedAt = action.visitedAt ?? new Date().toISOString();
485 ...state,480 const visitedAtMs = Date.parse(visitedAt);
486 projects: state.projects.map((project) =>
487 project.id === action.projectId
488 ? { ...project, scripts: cloneProjectScripts(action.scripts) }
489 : project,
490 ),
491 };
492
493 case "SET_THREADS_HYDRATED":
494 if (state.threadsHydrated === action.hydrated) {
495 return state;
496 }
497 return {481 return {
498 ...state,482 ...state,
499 threadsHydrated: action.hydrated,483 threads: updateThread(state.threads, action.threadId, (thread) => {
484 const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN;
485 if (
486 Number.isFinite(previousVisitedAtMs) &&
487 Number.isFinite(visitedAtMs) &&
488 previousVisitedAtMs >= visitedAtMs
489 ) {
490 return thread;
491 }
492 return {
493 ...thread,
494 lastVisitedAt: visitedAt,
495 };
496 }),
500 };497 };
498 }
501499
502 case "TOGGLE_PROJECT":500 case "TOGGLE_PROJECT":
503 return {501 return {
...@@ -540,10 +538,7 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -540,10 +538,7 @@ export function reducer(state: AppState, action: Action): AppState {
540 threads: updateThread(state.threads, action.threadId, (thread) => {538 threads: updateThread(state.threads, action.threadId, (thread) => {
541 const normalizedThread = normalizeThreadTerminals(thread);539 const normalizedThread = normalizeThreadTerminals(thread);
542 const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId);540 const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId);
543 if (541 if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {
544 isNewTerminal &&
545 normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT
546 ) {
547 return normalizedThread;542 return normalizedThread;
548 }543 }
549 const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)544 const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)
...@@ -613,10 +608,7 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -613,10 +608,7 @@ export function reducer(state: AppState, action: Action): AppState {
613 threads: updateThread(state.threads, action.threadId, (thread) => {608 threads: updateThread(state.threads, action.threadId, (thread) => {
614 const normalizedThread = normalizeThreadTerminals(thread);609 const normalizedThread = normalizeThreadTerminals(thread);
615 const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId);610 const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId);
616 if (611 if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {
617 isNewTerminal &&
618 normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT
619 ) {
620 return normalizedThread;612 return normalizedThread;
621 }613 }
622 const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)614 const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)