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";
22
33import { EDITORS, type EditorId } from "@t3tools/contracts";
44import { ServiceMap, Schema, Effect, Layer } from "effect";
5import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
65
76export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", {
87 message: Schema.String,
......@@ -26,7 +25,7 @@ export interface OpenShape {
2625
2726export class Open extends ServiceMap.Service<Open, OpenShape>()("server/Open") {}
2827
29function resolveEditorLaunch(
28export function resolveEditorLaunch(
3029 input: OpenInEditorInput,
3130 platform: NodeJS.Platform = process.platform,
3231): EditorLaunch {
......@@ -53,9 +52,25 @@ function resolveEditorLaunch(
5352 }
5453}
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
5672const make = Effect.gen(function* () {
5773 const open = yield* Effect.promise(() => import("open"));
58 const { spawn } = yield* ChildProcessSpawner.ChildProcessSpawner;
5974
6075 return {
6176 openBrowser: (target) =>
......@@ -64,14 +79,16 @@ const make = Effect.gen(function* () {
6479 catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }),
6580 }),
6681 openInEditor: (input) =>
67 Effect.gen(function* () {
68 const launch = resolveEditorLaunch(input);
69 const child = yield* spawn(ChildProcess.make(launch.command, launch.args));
70 yield* Effect.forkDetach(child.exitCode);
71 }).pipe(
72 Effect.catch((error) => Effect.logError("Failed to open editor", { cause: error })),
73 Effect.scoped,
74 ),
82 Effect.tryPromise({
83 try: () => {
84 const launch = resolveEditorLaunch(input);
85 return launchDetached(launch.command, launch.args);
86 },
87 catch: (cause) =>
88 cause instanceof OpenError
89 ? cause
90 : new OpenError({ message: "Failed to open editor", cause }),
91 }),
7592 } satisfies OpenShape;
7693});
7794
apps/web/src/components/ChatMarkdown.tsx+30-7
......@@ -1,20 +1,43 @@
1import { memo } from "react";
1import { memo, useMemo } from "react";
22import type { Components } from "react-markdown";
33import ReactMarkdown from "react-markdown";
44import rehypeHighlight from "rehype-highlight";
55import remarkGfm from "remark-gfm";
6import { useNativeApi } from "../hooks/useNativeApi";
7import { resolveMarkdownFileLinkTarget } from "../markdown-links";
8import { preferredTerminalEditor } from "../terminal-links";
69
710interface ChatMarkdownProps {
811 text: string;
12 cwd: string | undefined;
913}
1014
11const markdownComponents: Components = {
12 a({ node: _node, ...props }) {
13 return <a {...props} target="_blank" rel="noreferrer" />;
14 },
15};
15function ChatMarkdown({ text, cwd }: ChatMarkdownProps) {
16 const api = useNativeApi();
17 const markdownComponents = useMemo<Components>(
18 () => ({
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) {
1841 return (
1942 <div className="chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80">
2043 <ReactMarkdown
apps/web/src/components/ChatView.tsx+72-58
......@@ -344,7 +344,10 @@ interface ChatViewProps {
344344export default function ChatView({ threadId }: ChatViewProps) {
345345 const { state, dispatch } = useStore();
346346 const navigate = useNavigate();
347 const rawSearch = useSearch({ strict: false });
347 const rawSearch = useSearch({
348 strict: false,
349 select: (params) => parseDiffRouteSearch(params),
350 });
348351 const api = useNativeApi();
349352 const { resolvedTheme } = useTheme();
350353 const queryClient = useQueryClient();
......@@ -387,6 +390,26 @@ export default function ChatView({ threadId }: ChatViewProps) {
387390 const diffOpen = diffSearch.diff === "1";
388391 const activeThreadId = activeThread?.id ?? null;
389392 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
390413 const selectedModel = resolveModelSlug(
391414 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
392415 );
......@@ -799,47 +822,33 @@ export default function ChatView({ threadId }: ChatViewProps) {
799822 keybinding?: string | null;
800823 keybindingCommand: string;
801824 }) => {
802 dispatch({
803 type: "SET_PROJECT_SCRIPTS",
804 projectId: input.projectId,
825 if (!api) return;
826
827 await api.orchestration.dispatchCommand({
828 type: "project.meta.update",
829 commandId: newCommandId(),
830 projectId: asProjectId(input.projectId),
805831 scripts: input.nextScripts,
806832 });
807833
808 if (!api) return;
809 try {
810 await api.orchestration.dispatchCommand({
811 type: "project.meta.update",
812 commandId: newCommandId(),
813 projectId: asProjectId(input.projectId),
814 scripts: input.nextScripts,
834 if (isElectron && input.keybinding) {
835 const keybindingUpdate = await api.server.upsertKeybinding({
836 key: input.keybinding,
837 command: input.keybindingCommand,
815838 });
816
817 if (isElectron && input.keybinding) {
818 const keybindingUpdate = await api.server.upsertKeybinding({
819 key: input.keybinding,
820 command: input.keybindingCommand,
821 });
822 queryClient.setQueryData(
823 serverQueryKeys.config(),
824 (current: { cwd: string; keybindings: ResolvedKeybindingsConfig } | undefined) =>
825 current
826 ? { ...current, keybindings: keybindingUpdate.keybindings }
827 : {
828 cwd: input.projectCwd,
829 keybindings: keybindingUpdate.keybindings,
839 queryClient.setQueryData(
840 serverQueryKeys.config(),
841 (current: { cwd: string; keybindings: ResolvedKeybindingsConfig } | undefined) =>
842 current
843 ? { ...current, keybindings: keybindingUpdate.keybindings }
844 : {
845 cwd: input.projectCwd,
846 keybindings: keybindingUpdate.keybindings,
830847 },
831 );
832 }
833 } catch (error) {
834 dispatch({
835 type: "SET_PROJECT_SCRIPTS",
836 projectId: input.projectId,
837 scripts: input.previousScripts,
838 });
839 throw error;
848 );
840849 }
841850 },
842 [api, dispatch, queryClient],
851 [api, queryClient],
843852 );
844853 const saveProjectScript = useCallback(
845854 async (input: NewProjectScriptInput) => {
......@@ -1338,15 +1347,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
13381347 setIsRevertingCheckpoint(false);
13391348 }
13401349 },
1341 [
1342 activeThread,
1343 api,
1344 isConnecting,
1345 isRevertingCheckpoint,
1346 isSending,
1347 phase,
1348 setThreadError,
1349 ],
1350 [activeThread, api, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError],
13501351 );
13511352
13521353 const onSend = async (e: FormEvent) => {
......@@ -1425,7 +1426,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
14251426 try {
14261427 const turnAttachments = await Promise.all(
14271428 composerImagesSnapshot.map(
1428 async (image): Promise<{
1429 async (
1430 image,
1431 ): Promise<{
14291432 type: "image";
14301433 name: string;
14311434 mimeType: string;
......@@ -1749,12 +1752,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
17491752 onRevertUserMessage={onRevertUserMessage}
17501753 isRevertingCheckpoint={isRevertingCheckpoint}
17511754 onImageExpand={onExpandTimelineImage}
1755 markdownCwd={gitCwd ?? undefined}
17521756 />
17531757 </div>
17541758
17551759 {/* Input bar */}
17561760 <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 >
17581766 <div
17591767 className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring ${
17601768 isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border"
......@@ -1853,7 +1861,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
18531861
18541862 {/* Bottom toolbar */}
18551863 <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">
18571865 {/* Model picker */}
18581866 <ModelPicker model={selectedModel} onModelChange={onModelSelect} />
18591867
......@@ -1869,7 +1877,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
18691877 {/* Runtime mode toggle */}
18701878 <Button
18711879 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"
18731881 size="sm"
18741882 type="button"
18751883 disabled={isSwitchingRuntimeMode}
......@@ -2075,19 +2083,19 @@ const ChatHeader = memo(function ChatHeader({
20752083 onToggleDiff,
20762084}: ChatHeaderProps) {
20772085 return (
2078 <div className="flex min-w-0 flex-1 items-center justify-between gap-2">
2079 <div className="flex min-w-0 items-center gap-2 sm:gap-3">
2086 <div className="flex min-w-0 flex-1 items-center gap-2">
2087 <div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3">
20802088 <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}>
20822090 {activeThreadTitle}
20832091 </h2>
20842092 {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">
20862094 {activeProjectName}
20872095 </Badge>
20882096 )}
20892097 </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">
20912099 {activeProjectScripts && (
20922100 <ProjectScriptsControl
20932101 scripts={activeProjectScripts}
......@@ -2221,6 +2229,7 @@ interface MessagesTimelineProps {
22212229 onRevertUserMessage: (messageId: string) => void;
22222230 isRevertingCheckpoint: boolean;
22232231 onImageExpand: (image: ExpandedImagePreview) => void;
2232 markdownCwd: string | undefined;
22242233}
22252234
22262235type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];
......@@ -2256,6 +2265,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
22562265 onRevertUserMessage,
22572266 isRevertingCheckpoint,
22582267 onImageExpand,
2268 markdownCwd,
22592269}: MessagesTimelineProps) {
22602270 const rows = useMemo<TimelineRow[]>(() => {
22612271 const nextRows: TimelineRow[] = [];
......@@ -2448,7 +2458,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
24482458 </div>
24492459 )}
24502460 {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">
24522462 {row.message.text}
24532463 </pre>
24542464 )}
......@@ -2490,7 +2500,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
24902500 </div>
24912501 )}
24922502 <div className="min-w-0 px-1 py-0.5">
2493 <ChatMarkdown text={messageText} />
2503 <ChatMarkdown text={messageText} cwd={markdownCwd} />
24942504 {(() => {
24952505 const turnSummary = turnDiffSummaryByAssistantMessageId.get(
24962506 row.message.id,
......@@ -2567,7 +2577,9 @@ const MessagesTimeline = memo(function MessagesTimeline({
25672577 <span className="ml-1 text-muted-foreground/70">(</span>
25682578 <span className="text-success">+{stat.additions}</span>
25692579 <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>
25712583 <span className="text-muted-foreground/70">)</span>
25722584 </>
25732585 );
......@@ -2728,9 +2740,11 @@ const OpenInPicker = memo(function OpenInPicker({
27282740 <Group aria-label="Subscription actions">
27292741 <Button size="xs" variant="outline" onClick={() => openInEditor(lastEditor)}>
27302742 {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>
27322746 </Button>
2733 <GroupSeparator className="hidden sm:block" />
2747 <GroupSeparator className="hidden @sm/header-actions:block" />
27342748 <Menu>
27352749 <MenuTrigger render={<Button aria-label="Copy options" size="icon-xs" variant="outline" />}>
27362750 <ChevronDownIcon aria-hidden="true" className="size-4" />
apps/web/src/components/DiffPanel.tsx+35-33
......@@ -75,7 +75,7 @@ function formatTurnChipTimestamp(isoDate: string): string {
7575}
7676
7777interface DiffPanelProps {
78 mode?: "inline" | "sheet";
78 mode?: "inline" | "sheet" | "sidebar";
7979}
8080
8181export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
......@@ -271,7 +271,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
271271
272272 updateTurnStripScrollState();
273273 const onScroll = () => updateTurnStripScrollState();
274
274
275275 element.addEventListener("scroll", onScroll, { passive: true });
276276 element.addEventListener("wheel", onTurnStripWheel, { passive: false });
277277
......@@ -297,10 +297,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
297297 selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
298298 }, [selectedTurn?.turnId, selectedTurnId]);
299299
300 const shouldUseDragRegion = isElectron && mode === "inline";
300 const shouldUseDragRegion = isElectron && mode !== "sheet";
301301
302302 return (
303 <aside
303 <div
304304 className={cn(
305305 "flex h-full min-w-0 flex-col bg-card",
306306 mode === "inline"
......@@ -308,18 +308,19 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
308308 : "w-full",
309309 )}
310310 >
311 <div
312 className={cn(
313 "flex items-center justify-between gap-2 border-b border-border px-4",
314 shouldUseDragRegion ? "drag-region h-[52px]" : "py-3",
315 )}
316 >
317 <div className="relative min-w-0 flex-1 [-webkit-app-region:no-drag]">
311 <div className="border-b border-border">
312 <div
313 className={cn(
314 "flex h-12 items-center justify-between gap-2 px-4",
315 shouldUseDragRegion ? "drag-region" : undefined,
316 )}
317 >
318 <div className="relative min-w-0 flex-1 [-webkit-app-region:no-drag]">
318319 {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" />
320321 )}
321322 {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" />
323324 )}
324325 <button
325326 type="button"
......@@ -402,26 +403,27 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
402403 </button>
403404 ))}
404405 </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>
405426 </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>
425427 </div>
426428
427429 {!activeThread ? (
......@@ -488,6 +490,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
488490 </div>
489491 </>
490492 )}
491 </aside>
493 </div>
492494 );
493495}
apps/web/src/components/GitActionsControl.tsx+8-8
......@@ -136,11 +136,7 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
136136 return <InfoIcon className={iconClassName} />;
137137}
138138
139export default function GitActionsControl({
140 api,
141 gitCwd,
142 activeThreadId,
143}: GitActionsControlProps) {
139export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitActionsControlProps) {
144140 const threadToastData = useMemo(
145141 () => (activeThreadId ? { threadId: activeThreadId } : undefined),
146142 [activeThreadId],
......@@ -501,7 +497,9 @@ export default function GitActionsControl({
501497 }
502498 >
503499 <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>
505503 </PopoverTrigger>
506504 <PopoverPopup tooltipStyle side="bottom" align="start">
507505 {quickActionDisabledReason}
......@@ -515,10 +513,12 @@ export default function GitActionsControl({
515513 onClick={runQuickAction}
516514 >
517515 <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>
519519 </Button>
520520 )}
521 <GroupSeparator className="hidden sm:block" />
521 <GroupSeparator className="hidden @sm/header-actions:block" />
522522 <Menu
523523 onOpenChange={(open) => {
524524 if (open) void invalidateGitQueries(queryClient);
apps/web/src/components/ProjectScriptsControl.tsx+7-3
......@@ -266,9 +266,11 @@ export default function ProjectScriptsControl({
266266 title={`Run ${primaryScript.name}`}
267267 >
268268 <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>
270272 </Button>
271 <GroupSeparator className="hidden sm:block" />
273 <GroupSeparator className="hidden @sm/header-actions:block" />
272274 <Menu highlightItemOnHover={false}>
273275 <MenuTrigger
274276 render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />}
......@@ -329,7 +331,9 @@ export default function ProjectScriptsControl({
329331 ) : (
330332 <Button size="xs" variant="outline" onClick={openAddDialog} title="Add action">
331333 <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>
333337 </Button>
334338 )}
335339
apps/web/src/components/Sidebar.tsx-3
......@@ -33,7 +33,6 @@ import {
3333 SidebarMenuSub,
3434 SidebarMenuSubButton,
3535 SidebarMenuSubItem,
36 SidebarRail,
3736 SidebarSeparator,
3837 SidebarTrigger,
3938} from "./ui/sidebar";
......@@ -710,8 +709,6 @@ export default function Sidebar() {
710709 </button>
711710 )}
712711 </SidebarFooter>
713
714 <SidebarRail />
715712 </>
716713 );
717714}
apps/web/src/components/ui/sidebar.tsx+377-77
......@@ -25,6 +25,7 @@ const SIDEBAR_WIDTH = "16rem";
2525const SIDEBAR_WIDTH_MOBILE = "calc(100vw - var(--spacing(3)))";
2626const SIDEBAR_WIDTH_ICON = "3rem";
2727const SIDEBAR_KEYBOARD_SHORTCUT = "b";
28const SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH = 16 * 16;
2829
2930type SidebarContextProps = {
3031 state: "expanded" | "collapsed";
......@@ -36,7 +37,43 @@ type SidebarContextProps = {
3637 toggleSidebar: () => void;
3738};
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
3975const SidebarContext = React.createContext<SidebarContextProps | null>(null);
76const SidebarInstanceContext = React.createContext<SidebarInstanceContextProps | null>(null);
4077
4178function useSidebar() {
4279 const context = React.useContext(SidebarContext);
......@@ -149,6 +186,7 @@ function Sidebar({
149186 side = "left",
150187 variant = "sidebar",
151188 collapsible = "offcanvas",
189 resizable = false,
152190 className,
153191 children,
154192 ...props
......@@ -156,98 +194,123 @@ function Sidebar({
156194 side?: "left" | "right";
157195 variant?: "sidebar" | "floating" | "inset";
158196 collapsible?: "offcanvas" | "icon" | "none";
197 resizable?: boolean | SidebarResizableOptions;
159198}) {
160199 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
162219 if (collapsible === "none") {
163220 return (
164 <div
165 className={cn(
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
221 <SidebarInstanceContext.Provider value={instanceContextValue}>
222 <div
181223 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",
183225 className,
184226 )}
185 data-mobile="true"
186 data-sidebar="sidebar"
187227 data-slot="sidebar"
188 showCloseButton={false}
189 side={side}
190 style={
191 {
192 "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
193 } as React.CSSProperties
194 }
228 {...props}
195229 >
196 <SheetHeader className="sr-only">
197 <SheetTitle>Sidebar</SheetTitle>
198 <SheetDescription>Displays the mobile sidebar.</SheetDescription>
199 </SheetHeader>
200 <div className="flex h-full w-full flex-col">{children}</div>
201 </SheetPopup>
202 </Sheet>
230 {children}
231 </div>
232 </SidebarInstanceContext.Provider>
233 );
234 }
235
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>
203264 );
204265 }
205266
206267 return (
207 <div
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 */}
268 <SidebarInstanceContext.Provider value={instanceContextValue}>
216269 <div
217 className={cn(
218 "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
219 "group-data-[collapsible=offcanvas]:w-0",
220 "group-data-[side=right]:rotate-180",
221 variant === "floating" || variant === "inset"
222 ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
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}
270 className="group peer hidden text-sidebar-foreground md:block"
271 data-collapsible={state === "collapsed" ? collapsible : ""}
272 data-side={side}
273 data-slot="sidebar"
274 data-state={state}
275 data-variant={variant}
241276 >
277 {/* This is what handles the sidebar gap on desktop */}
242278 <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"
244 data-sidebar="sidebar"
245 data-slot="sidebar-inner"
279 className={cn(
280 "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
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}
246303 >
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>
248311 </div>
249312 </div>
250 </div>
313 </SidebarInstanceContext.Provider>
251314 );
252315}
253316
......@@ -273,12 +336,244 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t
273336 );
274337}
275338
276function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
277 const { toggleSidebar } = useSidebar();
339function clampSidebarWidth(width: number, options: SidebarResolvedResizableOptions): number {
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
279574 return (
280575 <button
281 aria-label="Toggle Sidebar"
576 aria-label={railLabel}
282577 className={cn(
283578 "-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",
284579 "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">) {
290585 )}
291586 data-sidebar="rail"
292587 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}
294594 tabIndex={-1}
295 title="Toggle Sidebar"
595 title={railTitle}
296596 type="button"
297597 {...props}
298598 />
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"
66import { useMediaQuery } from "../hooks/useMediaQuery";
77import { useStore } from "../store";
88import { Sheet, SheetPopup } from "../components/ui/sheet";
9import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar";
910
1011const DiffPanel = lazy(() => import("../components/DiffPanel"));
1112const 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: {
1418 children: ReactNode;
15 sheet: boolean;
1619 diffOpen: boolean;
1720 onCloseDiff: () => void;
1821}) => {
19 if (props.sheet) {
20 return (
21 <Sheet
22 open={props.diffOpen}
23 onOpenChange={(open) => {
24 if (!open) {
25 props.onCloseDiff();
26 }
27 }}
22 return (
23 <Sheet
24 open={props.diffOpen}
25 onOpenChange={(open) => {
26 if (!open) {
27 props.onCloseDiff();
28 }
29 }}
30 >
31 <SheetPopup
32 side="right"
33 showCloseButton={false}
34 keepMounted
35 className="w-[min(88vw,820px)] max-w-[820px] p-0"
2836 >
29 <SheetPopup
30 side="right"
31 showCloseButton={false}
32 keepMounted
33 className="w-[min(88vw,820px)] max-w-[820px] p-0"
34 >
35 {props.children}
36 </SheetPopup>
37 </Sheet>
37 {props.children}
38 </SheetPopup>
39 </Sheet>
40 );
41};
42
43const DiffLoadingFallback = (props: { inline: boolean }) => {
44 if (props.inline) {
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>
3849 );
3950 }
4051
4152 return (
42 <aside className={props.diffOpen ? undefined : "hidden"} aria-hidden={!props.diffOpen}>
43 {props.children}
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">
54 Loading diff viewer...
4455 </aside>
4556 );
4657};
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
48133function ChatThreadRouteView() {
49134 const { state } = useStore();
50135 const navigate = useNavigate();
......@@ -62,6 +147,16 @@ function ChatThreadRouteView() {
62147 },
63148 });
64149 }, [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
66161 useEffect(() => {
67162 if (!threadId) {
......@@ -83,25 +178,27 @@ function ChatThreadRouteView() {
83178 return null;
84179 }
85180
86 const diffLoadingFallback =
87 !diffOpen || shouldUseDiffSheet ? (
88 <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70">
89 Loading diff viewer...
90 </div>
91 ) : (
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">
93 Loading diff viewer...
94 </aside>
181 if (!shouldUseDiffSheet) {
182 return (
183 <>
184 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
185 <ChatView threadId={threadId} />
186 </SidebarInset>
187 <DiffPanelInlineSidebar diffOpen={diffOpen} onCloseDiff={closeDiff} onOpenDiff={openDiff} />
188 </>
95189 );
190 }
96191
97192 return (
98193 <>
99 <ChatView threadId={threadId} />
100 <DiffPanelWrapper sheet={shouldUseDiffSheet} diffOpen={diffOpen} onCloseDiff={closeDiff}>
101 <Suspense fallback={diffLoadingFallback}>
102 <DiffPanel mode={shouldUseDiffSheet ? "sheet" : "inline"} />
194 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
195 <ChatView threadId={threadId} />
196 </SidebarInset>
197 <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}>
198 <Suspense fallback={<DiffLoadingFallback inline={false} />}>
199 <DiffPanel mode="sheet" />
103200 </Suspense>
104 </DiffPanelWrapper>
201 </DiffPanelSheet>
105202 </>
106203 );
107204}
apps/web/src/routes/_chat.settings.tsx+186-180
......@@ -7,6 +7,7 @@ import { useStore } from "../store";
77import { Button } from "../components/ui/button";
88import { Input } from "../components/ui/input";
99import { Switch } from "../components/ui/switch";
10import { SidebarInset } from "~/components/ui/sidebar";
1011
1112const THEME_OPTIONS = [
1213 {
......@@ -48,209 +49,214 @@ function SettingsRouteView() {
4849 const codexHomePath = settings.codexHomePath;
4950
5051 return (
51 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
52 {isElectron && (
53 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
54 <span className="text-xs font-medium tracking-wide text-muted-foreground/70">Settings</span>
55 </div>
56 )}
57
58 <div className="flex-1 overflow-y-auto p-6">
59 <div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
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>
52 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
53 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
54 {isElectron && (
55 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
56 <span className="text-xs font-medium tracking-wide text-muted-foreground/70">
57 Settings
58 </span>
59 </div>
60 )}
6661
67 <section className="rounded-2xl border border-border bg-card p-5">
68 <div className="mb-4">
69 <h2 className="text-sm font-medium text-foreground">Appearance</h2>
70 <p className="mt-1 text-xs text-muted-foreground">
71 Choose how T3 Code handles light and dark mode.
62 <div className="flex-1 overflow-y-auto p-6">
63 <div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
64 <header className="space-y-1">
65 <h1 className="text-2xl font-semibold tracking-tight text-foreground">Settings</h1>
66 <p className="text-sm text-muted-foreground">
67 Configure app-level preferences for this device.
7268 </p>
73 </div>
69 </header>
7470
75 <div className="space-y-2" role="radiogroup" aria-label="Theme preference">
76 {THEME_OPTIONS.map((option) => {
77 const selected = theme === option.value;
78 return (
79 <button
80 key={option.value}
81 type="button"
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>
71 <section className="rounded-2xl border border-border bg-card p-5">
72 <div className="mb-4">
73 <h2 className="text-sm font-medium text-foreground">Appearance</h2>
74 <p className="mt-1 text-xs text-muted-foreground">
75 Choose how T3 Code handles light and dark mode.
76 </p>
77 </div>
10478
105 <p className="mt-4 text-xs text-muted-foreground">
106 Active theme: <span className="font-medium text-foreground">{resolvedTheme}</span>
107 </p>
108 </section>
79 <div className="space-y-2" role="radiogroup" aria-label="Theme preference">
80 {THEME_OPTIONS.map((option) => {
81 const selected = theme === option.value;
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">
111 <div className="mb-4">
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.
109 <p className="mt-4 text-xs text-muted-foreground">
110 Active theme: <span className="font-medium text-foreground">{resolvedTheme}</span>
115111 </p>
116 </div>
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>
112 </section>
131113
132 <label className="block space-y-1">
133 <span className="text-xs font-medium text-foreground">CODEX_HOME path</span>
134 <Input
135 value={codexHomePath}
136 onChange={(event) => updateSettings({ codexHomePath: event.target.value })}
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>
114 <section className="rounded-2xl border border-border bg-card p-5">
115 <div className="mb-4">
116 <h2 className="text-sm font-medium text-foreground">Codex App Server</h2>
117 <p className="mt-1 text-xs text-muted-foreground">
118 These overrides apply to new sessions and let you use a non-default Codex install.
148119 </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>
161120 </div>
162 </div>
163 </section>
164121
165 <section className="rounded-2xl border border-border bg-card p-5">
166 <div className="mb-4">
167 <h2 className="text-sm font-medium text-foreground">Runtime Mode</h2>
168 <p className="mt-1 text-xs text-muted-foreground">
169 Select the default execution policy for this client.
170 </p>
171 </div>
122 <div className="space-y-4">
123 <label className="block space-y-1">
124 <span className="text-xs font-medium text-foreground">Codex binary path</span>
125 <Input
126 value={codexBinaryPath}
127 onChange={(event) => updateSettings({ codexBinaryPath: event.target.value })}
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">
174 {RUNTIME_MODE_OPTIONS.map((option) => {
175 const selected = state.runtimeMode === option.value;
176 return (
177 <button
178 key={option.value}
179 type="button"
180 role="radio"
181 aria-checked={selected}
182 className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${
183 selected
184 ? "border-primary/60 bg-primary/8 text-foreground"
185 : "border-border bg-background text-muted-foreground hover:bg-accent"
186 }`}
187 onClick={() => {
188 dispatch({
189 type: "SET_RUNTIME_MODE",
190 mode: option.value,
191 });
192 }}
136 <label className="block space-y-1">
137 <span className="text-xs font-medium text-foreground">CODEX_HOME path</span>
138 <Input
139 value={codexHomePath}
140 onChange={(event) => updateSettings({ codexHomePath: event.target.value })}
141 placeholder="/Users/you/.codex"
142 spellCheck={false}
143 />
144 <span className="text-xs text-muted-foreground">
145 Optional custom Codex home/config directory.
146 </span>
147 </label>
148
149 <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
150 <p>
151 Binary source:{" "}
152 <span className="font-medium text-foreground">{codexBinaryPath || "PATH"}</span>
153 </p>
154 <Button
155 size="xs"
156 variant="outline"
157 onClick={() =>
158 updateSettings({
159 codexBinaryPath: defaults.codexBinaryPath,
160 codexHomePath: defaults.codexHomePath,
161 })
162 }
193163 >
194 <span className="flex flex-col">
195 <span className="text-sm font-medium">{option.label}</span>
196 <span className="text-xs">{option.description}</span>
197 </span>
198 {selected ? (
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>
164 Reset codex overrides
165 </Button>
166 </div>
167 </div>
168 </section>
208169
209 <section className="rounded-2xl border border-border bg-card p-5">
210 <div className="mb-4">
211 <h2 className="text-sm font-medium text-foreground">Safety</h2>
212 <p className="mt-1 text-xs text-muted-foreground">
213 Additional guardrails for destructive local actions.
214 </p>
215 </div>
170 <section className="rounded-2xl border border-border bg-card p-5">
171 <div className="mb-4">
172 <h2 className="text-sm font-medium text-foreground">Runtime Mode</h2>
173 <p className="mt-1 text-xs text-muted-foreground">
174 Select the default execution policy for this client.
175 </p>
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">
218 <div>
219 <p className="text-sm font-medium text-foreground">Confirm thread deletion</p>
220 <p className="text-xs text-muted-foreground">
221 Ask for confirmation before deleting a thread and its chat history.
214 <section className="rounded-2xl border border-border bg-card p-5">
215 <div className="mb-4">
216 <h2 className="text-sm font-medium text-foreground">Safety</h2>
217 <p className="mt-1 text-xs text-muted-foreground">
218 Additional guardrails for destructive local actions.
222219 </p>
223220 </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 ? (
236 <div className="mt-3 flex justify-end">
237 <Button
238 size="xs"
239 variant="outline"
240 onClick={() =>
222 <div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2">
223 <div>
224 <p className="text-sm font-medium text-foreground">Confirm thread deletion</p>
225 <p className="text-xs text-muted-foreground">
226 Ask for confirmation before deleting a thread and its chat history.
227 </p>
228 </div>
229 <Switch
230 checked={settings.confirmThreadDelete}
231 onCheckedChange={(checked) =>
241232 updateSettings({
242 confirmThreadDelete: defaults.confirmThreadDelete,
233 confirmThreadDelete: Boolean(checked),
243234 })
244235 }
245 >
246 Restore default
247 </Button>
236 aria-label="Confirm thread deletion"
237 />
248238 </div>
249 ) : null}
250 </section>
239
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>
251257 </div>
252258 </div>
253 </div>
259 </SidebarInset>
254260 );
255261}
256262
apps/web/src/routes/_chat.tsx+3-5
......@@ -33,11 +33,9 @@ function ChatRouteLayout() {
3333 >
3434 <ThreadSidebar />
3535 </Sidebar>
36 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
37 <DiffWorkerPoolProvider>
38 <Outlet />
39 </DiffWorkerPoolProvider>
40 </SidebarInset>
36 <DiffWorkerPoolProvider>
37 <Outlet />
38 </DiffWorkerPoolProvider>
4139 </SidebarProvider>
4240 );
4341}
apps/web/src/store.ts+113-121
......@@ -8,10 +8,7 @@ import {
88 useReducer,
99} from "react";
1010
11import {
12 type OrchestrationReadModel,
13 type OrchestrationSessionStatus,
14} from "@t3tools/contracts";
11import { type OrchestrationReadModel, type OrchestrationSessionStatus } from "@t3tools/contracts";
1512import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
1613import {
1714 DEFAULT_THREAD_TERMINAL_HEIGHT,
......@@ -30,8 +27,7 @@ import {
3027
3128type Action =
3229 | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel }
33 | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] }
34 | { type: "SET_THREADS_HYDRATED"; hydrated: boolean }
30 | { type: "MARK_THREAD_VISITED"; threadId: string; visitedAt?: string }
3531 | { type: "TOGGLE_PROJECT"; projectId: string }
3632 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string }
3733 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean }
......@@ -137,10 +133,6 @@ function updateThread(
137133 return threads.map((t) => (t.id === threadId ? updater(t) : t));
138134}
139135
140function cloneProjectScripts(scripts: ReadonlyArray<ProjectScript>): ProjectScript[] {
141 return scripts.map((script) => ({ ...script }));
142}
143
144136function mapProjectsFromReadModel(
145137 incoming: OrchestrationReadModel["projects"],
146138 previous: Project[],
......@@ -159,7 +151,7 @@ function mapProjectsFromReadModel(
159151 (persistedExpandedProjectCwds.size > 0
160152 ? persistedExpandedProjectCwds.has(project.workspaceRoot)
161153 : true),
162 scripts: cloneProjectScripts(project.scripts),
154 scripts: project.scripts.map((script) => ({ ...script })),
163155 };
164156 });
165157}
......@@ -187,7 +179,6 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" {
187179 return providerName === "claudeCode" ? "claudeCode" : "codex";
188180}
189181
190
191182function normalizeTerminalIds(terminalIds: string[]): string[] {
192183 const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0);
193184 const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT);
......@@ -208,7 +199,6 @@ function normalizeRunningTerminalIds(
208199 .filter((id) => id.length > 0 && validTerminalIdSet.has(id));
209200}
210201
211
212202function normalizeTerminalGroupIds(terminalIds: string[]): string[] {
213203 return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
214204}
......@@ -374,7 +364,6 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread {
374364 });
375365}
376366
377
378367// ── Reducer ──────────────────────────────────────────────────────────
379368
380369export function reducer(state: AppState, action: Action): AppState {
......@@ -384,94 +373,100 @@ export function reducer(state: AppState, action: Action): AppState {
384373 action.readModel.projects.filter((project) => project.deletedAt === null),
385374 state.projects,
386375 );
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 );
388379 const threads = action.readModel.threads
389380 .filter((thread) => thread.deletedAt === null)
390381 .map((thread) => {
391 const existing = existingThreadById.get(thread.id);
392 const latestTurnStartedAt =
393 thread.latestTurnId === null
394 ? undefined
395 : thread.messages.find(
396 (message) => message.turnId === thread.latestTurnId && message.role === "user",
397 )?.createdAt;
398 const latestTurnCompletedAt = thread.checkpoints.find(
399 (checkpoint) => checkpoint.turnId === thread.latestTurnId,
400 )?.completedAt;
401 const latestTurnDurationMs =
402 latestTurnStartedAt && latestTurnCompletedAt
403 ? Math.max(0, Date.parse(latestTurnCompletedAt) - Date.parse(latestTurnStartedAt))
404 : undefined;
405
406 return normalizeThreadTerminals({
407 id: thread.id,
408 codexThreadId: thread.session?.providerThreadId ?? null,
409 projectId: thread.projectId,
410 title: thread.title,
411 model: resolveModelSlug(thread.model),
412 terminalOpen: existing?.terminalOpen ?? false,
413 terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,
414 terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID],
415 runningTerminalIds: existing?.runningTerminalIds ?? [],
416 activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID,
417 terminalGroups: existing?.terminalGroups ?? [
418 { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] },
419 ],
420 activeTerminalGroupId: existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`,
421 session: thread.session
422 ? {
423 sessionId: thread.session.providerSessionId ?? `thread:${thread.id}`,
424 provider: toLegacyProvider(thread.session.providerName),
425 status: toLegacySessionStatus(thread.session.status),
426 orchestrationStatus: thread.session.status,
427 threadId: thread.session.providerThreadId,
428 activeTurnId: thread.session.activeTurnId ?? undefined,
429 createdAt: thread.session.updatedAt,
430 updatedAt: thread.session.updatedAt,
431 ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}),
432 }
433 : null,
434 messages: thread.messages.map((message) => {
435 const attachments = message.attachments?.map((attachment, index) => ({
436 type: "image" as const,
437 id: `${message.id}:${index}`,
438 name: attachment.name,
439 mimeType: attachment.mimeType,
440 sizeBytes: attachment.sizeBytes,
441 previewUrl: attachment.dataUrl,
442 }));
443 const normalizedMessage: ChatMessage = {
444 id: message.id,
445 role: message.role,
446 text: message.text,
447 createdAt: message.createdAt,
448 streaming: message.streaming,
449 ...(message.streaming ? {} : { completedAt: message.updatedAt }),
450 ...(attachments && attachments.length > 0 ? { attachments } : {}),
451 };
452 return normalizedMessage;
453 }),
454 error: thread.session?.lastError ?? null,
455 createdAt: thread.createdAt,
456 latestTurnId: thread.latestTurnId ?? undefined,
457 latestTurnStartedAt,
458 latestTurnCompletedAt,
459 latestTurnDurationMs,
460 lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt,
461 branch: thread.branch,
462 worktreePath: thread.worktreePath,
463 turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({
464 turnId: checkpoint.turnId,
465 completedAt: checkpoint.completedAt,
466 status: checkpoint.status,
467 assistantMessageId: checkpoint.assistantMessageId ?? undefined,
468 checkpointTurnCount: checkpoint.checkpointTurnCount,
469 checkpointRef: checkpoint.checkpointRef,
470 files: checkpoint.files.map((file) => ({ ...file })),
471 })),
472 activities: thread.activities.map((activity) => ({ ...activity })),
382 const existing = existingThreadById.get(thread.id);
383 const latestTurnStartedAt =
384 thread.latestTurnId === null
385 ? undefined
386 : thread.messages.find(
387 (message) => message.turnId === thread.latestTurnId && message.role === "user",
388 )?.createdAt;
389 const latestTurnCompletedAt = thread.checkpoints.find(
390 (checkpoint) => checkpoint.turnId === thread.latestTurnId,
391 )?.completedAt;
392 const latestTurnDurationMs =
393 latestTurnStartedAt && latestTurnCompletedAt
394 ? Math.max(0, Date.parse(latestTurnCompletedAt) - Date.parse(latestTurnStartedAt))
395 : undefined;
396
397 return normalizeThreadTerminals({
398 id: thread.id,
399 codexThreadId: thread.session?.providerThreadId ?? null,
400 projectId: thread.projectId,
401 title: thread.title,
402 model: resolveModelSlug(thread.model),
403 terminalOpen: existing?.terminalOpen ?? false,
404 terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,
405 terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID],
406 runningTerminalIds: existing?.runningTerminalIds ?? [],
407 activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID,
408 terminalGroups: existing?.terminalGroups ?? [
409 {
410 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
411 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
412 },
413 ],
414 activeTerminalGroupId:
415 existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`,
416 session: thread.session
417 ? {
418 sessionId: thread.session.providerSessionId ?? `thread:${thread.id}`,
419 provider: toLegacyProvider(thread.session.providerName),
420 status: toLegacySessionStatus(thread.session.status),
421 orchestrationStatus: thread.session.status,
422 threadId: thread.session.providerThreadId,
423 activeTurnId: thread.session.activeTurnId ?? undefined,
424 createdAt: thread.session.updatedAt,
425 updatedAt: thread.session.updatedAt,
426 ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}),
427 }
428 : null,
429 messages: thread.messages.map((message) => {
430 const attachments = message.attachments?.map((attachment, index) => ({
431 type: "image" as const,
432 id: `${message.id}:${index}`,
433 name: attachment.name,
434 mimeType: attachment.mimeType,
435 sizeBytes: attachment.sizeBytes,
436 previewUrl: attachment.dataUrl,
437 }));
438 const normalizedMessage: ChatMessage = {
439 id: message.id,
440 role: message.role,
441 text: message.text,
442 createdAt: message.createdAt,
443 streaming: message.streaming,
444 ...(message.streaming ? {} : { completedAt: message.updatedAt }),
445 ...(attachments && attachments.length > 0 ? { attachments } : {}),
446 };
447 return normalizedMessage;
448 }),
449 error: thread.session?.lastError ?? null,
450 createdAt: thread.createdAt,
451 latestTurnId: thread.latestTurnId ?? undefined,
452 latestTurnStartedAt,
453 latestTurnCompletedAt,
454 latestTurnDurationMs,
455 lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt,
456 branch: thread.branch,
457 worktreePath: thread.worktreePath,
458 turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({
459 turnId: checkpoint.turnId,
460 completedAt: checkpoint.completedAt,
461 status: checkpoint.status,
462 assistantMessageId: checkpoint.assistantMessageId ?? undefined,
463 checkpointTurnCount: checkpoint.checkpointTurnCount,
464 checkpointRef: checkpoint.checkpointRef,
465 files: checkpoint.files.map((file) => ({ ...file })),
466 })),
467 activities: thread.activities.map((activity) => ({ ...activity })),
468 });
473469 });
474 });
475470 return {
476471 ...state,
477472 projects,
......@@ -480,24 +475,27 @@ export function reducer(state: AppState, action: Action): AppState {
480475 };
481476 }
482477
483 case "SET_PROJECT_SCRIPTS":
484 return {
485 ...state,
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 }
478 case "MARK_THREAD_VISITED": {
479 const visitedAt = action.visitedAt ?? new Date().toISOString();
480 const visitedAtMs = Date.parse(visitedAt);
497481 return {
498482 ...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 }),
500497 };
498 }
501499
502500 case "TOGGLE_PROJECT":
503501 return {
......@@ -540,10 +538,7 @@ export function reducer(state: AppState, action: Action): AppState {
540538 threads: updateThread(state.threads, action.threadId, (thread) => {
541539 const normalizedThread = normalizeThreadTerminals(thread);
542540 const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId);
543 if (
544 isNewTerminal &&
545 normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT
546 ) {
541 if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {
547542 return normalizedThread;
548543 }
549544 const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)
......@@ -613,10 +608,7 @@ export function reducer(state: AppState, action: Action): AppState {
613608 threads: updateThread(state.threads, action.threadId, (thread) => {
614609 const normalizedThread = normalizeThreadTerminals(thread);
615610 const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId);
616 if (
617 isNewTerminal &&
618 normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT
619 ) {
611 if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {
620612 return normalizedThread;
621613 }
622614 const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)