| author | |
| committer | |
| log | ce96ad0b61b58db07f6cc197464032b11b590890 |
| tree | be9c5e3a7a646d4c4aec0c3cfc2182278f98dc02 |
| parent | 9ef1c0c9a3c056fd0ed309b5b0ba0cda72848dd0 |
15 files changed, 1202 insertions(+), 542 deletions(-)
apps/server/src/open.test.ts created+47| ... | ... | @@ -0,0 +1,47 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { launchDetached, resolveEditorLaunch } from "./open"; | |
| 4 | ||
| 5 | describe("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 | ||
| 35 | describe("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 | 2 | |
| 3 | 3 | import { EDITORS, type EditorId } from "@t3tools/contracts"; |
| 4 | 4 | import { ServiceMap, Schema, Effect, Layer } from "effect"; |
| 5 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 6 | 5 | |
| 7 | 6 | export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", { |
| 8 | 7 | message: Schema.String, |
| ... | ... | @@ -26,7 +25,7 @@ export interface OpenShape { |
| 26 | 25 | |
| 27 | 26 | export class Open extends ServiceMap.Service<Open, OpenShape>()("server/Open") {} |
| 28 | 27 | |
| 29 | function resolveEditorLaunch( | |
| 28 | export function resolveEditorLaunch( | |
| 30 | 29 | input: OpenInEditorInput, |
| 31 | 30 | platform: NodeJS.Platform = process.platform, |
| 32 | 31 | ): EditorLaunch { |
| ... | ... | @@ -53,9 +52,25 @@ function resolveEditorLaunch( |
| 53 | 52 | } |
| 54 | 53 | } |
| 55 | 54 | |
| 55 | export 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 | ||
| 56 | 72 | const make = Effect.gen(function* () { |
| 57 | 73 | const open = yield* Effect.promise(() => import("open")); |
| 58 | const { spawn } = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 59 | 74 | |
| 60 | 75 | return { |
| 61 | 76 | openBrowser: (target) => |
| ... | ... | @@ -64,14 +79,16 @@ const make = Effect.gen(function* () { |
| 64 | 79 | catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }), |
| 65 | 80 | }), |
| 66 | 81 | 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 | }), | |
| 75 | 92 | } satisfies OpenShape; |
| 76 | 93 | }); |
| 77 | 94 |
apps/web/src/components/ChatMarkdown.tsx+30-7| ... | ... | @@ -1,20 +1,43 @@ |
| 1 | import { memo } from "react"; | |
| 1 | import { memo, useMemo } from "react"; | |
| 2 | 2 | import type { Components } from "react-markdown"; |
| 3 | 3 | import ReactMarkdown from "react-markdown"; |
| 4 | 4 | import rehypeHighlight from "rehype-highlight"; |
| 5 | 5 | import remarkGfm from "remark-gfm"; |
| 6 | import { useNativeApi } from "../hooks/useNativeApi"; | |
| 7 | import { resolveMarkdownFileLinkTarget } from "../markdown-links"; | |
| 8 | import { preferredTerminalEditor } from "../terminal-links"; | |
| 6 | 9 | |
| 7 | 10 | interface ChatMarkdownProps { |
| 8 | 11 | text: string; |
| 12 | cwd: string | undefined; | |
| 9 | 13 | } |
| 10 | 14 | |
| 11 | const markdownComponents: Components = { | |
| 12 | a({ node: _node, ...props }) { | |
| 13 | return <a {...props} target="_blank" rel="noreferrer" />; | |
| 14 | }, | |
| 15 | }; | |
| 15 | function 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 | ); | |
| 16 | 40 | |
| 17 | function ChatMarkdown({ text }: ChatMarkdownProps) { | |
| 18 | 41 | return ( |
| 19 | 42 | <div className="chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80"> |
| 20 | 43 | <ReactMarkdown |
apps/web/src/components/ChatView.tsx+72-58| ... | ... | @@ -344,7 +344,10 @@ interface ChatViewProps { |
| 344 | 344 | export default function ChatView({ threadId }: ChatViewProps) { |
| 345 | 345 | const { state, dispatch } = useStore(); |
| 346 | 346 | const navigate = useNavigate(); |
| 347 | const rawSearch = useSearch({ strict: false }); | |
| 347 | const rawSearch = useSearch({ | |
| 348 | strict: false, | |
| 349 | select: (params) => parseDiffRouteSearch(params), | |
| 350 | }); | |
| 348 | 351 | const api = useNativeApi(); |
| 349 | 352 | const { resolvedTheme } = useTheme(); |
| 350 | 353 | const queryClient = useQueryClient(); |
| ... | ... | @@ -387,6 +390,26 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 387 | 390 | const diffOpen = diffSearch.diff === "1"; |
| 388 | 391 | const activeThreadId = activeThread?.id ?? null; |
| 389 | 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 | 413 | const selectedModel = resolveModelSlug( |
| 391 | 414 | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, |
| 392 | 415 | ); |
| ... | ... | @@ -799,47 +822,33 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 799 | 822 | keybinding?: string | null; |
| 800 | 823 | keybindingCommand: string; |
| 801 | 824 | }) => { |
| 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), | |
| 805 | 831 | scripts: input.nextScripts, |
| 806 | 832 | }); |
| 807 | 833 | |
| 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, | |
| 815 | 838 | }); |
| 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, | |
| 830 | 847 | }, |
| 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 | ); | |
| 840 | 849 | } |
| 841 | 850 | }, |
| 842 | [api, dispatch, queryClient], | |
| 851 | [api, queryClient], | |
| 843 | 852 | ); |
| 844 | 853 | const saveProjectScript = useCallback( |
| 845 | 854 | async (input: NewProjectScriptInput) => { |
| ... | ... | @@ -1338,15 +1347,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1338 | 1347 | setIsRevertingCheckpoint(false); |
| 1339 | 1348 | } |
| 1340 | 1349 | }, |
| 1341 | [ | |
| 1342 | activeThread, | |
| 1343 | api, | |
| 1344 | isConnecting, | |
| 1345 | isRevertingCheckpoint, | |
| 1346 | isSending, | |
| 1347 | phase, | |
| 1348 | setThreadError, | |
| 1349 | ], | |
| 1350 | [activeThread, api, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError], | |
| 1350 | 1351 | ); |
| 1351 | 1352 | |
| 1352 | 1353 | const onSend = async (e: FormEvent) => { |
| ... | ... | @@ -1425,7 +1426,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1425 | 1426 | try { |
| 1426 | 1427 | const turnAttachments = await Promise.all( |
| 1427 | 1428 | composerImagesSnapshot.map( |
| 1428 | async (image): Promise<{ | |
| 1429 | async ( | |
| 1430 | image, | |
| 1431 | ): Promise<{ | |
| 1429 | 1432 | type: "image"; |
| 1430 | 1433 | name: string; |
| 1431 | 1434 | mimeType: string; |
| ... | ... | @@ -1749,12 +1752,17 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1749 | 1752 | onRevertUserMessage={onRevertUserMessage} |
| 1750 | 1753 | isRevertingCheckpoint={isRevertingCheckpoint} |
| 1751 | 1754 | onImageExpand={onExpandTimelineImage} |
| 1755 | markdownCwd={gitCwd ?? undefined} | |
| 1752 | 1756 | /> |
| 1753 | 1757 | </div> |
| 1754 | 1758 | |
| 1755 | 1759 | {/* Input bar */} |
| 1756 | 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 | 1766 | <div |
| 1759 | 1767 | className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring ${ |
| 1760 | 1768 | isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border" |
| ... | ... | @@ -1853,7 +1861,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1853 | 1861 | |
| 1854 | 1862 | {/* Bottom toolbar */} |
| 1855 | 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 | 1865 | {/* Model picker */} |
| 1858 | 1866 | <ModelPicker model={selectedModel} onModelChange={onModelSelect} /> |
| 1859 | 1867 | |
| ... | ... | @@ -1869,7 +1877,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1869 | 1877 | {/* Runtime mode toggle */} |
| 1870 | 1878 | <Button |
| 1871 | 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 | 1881 | size="sm" |
| 1874 | 1882 | type="button" |
| 1875 | 1883 | disabled={isSwitchingRuntimeMode} |
| ... | ... | @@ -2075,19 +2083,19 @@ const ChatHeader = memo(function ChatHeader({ |
| 2075 | 2083 | onToggleDiff, |
| 2076 | 2084 | }: ChatHeaderProps) { |
| 2077 | 2085 | 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"> | |
| 2080 | 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 | 2090 | {activeThreadTitle} |
| 2083 | 2091 | </h2> |
| 2084 | 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 | 2094 | {activeProjectName} |
| 2087 | 2095 | </Badge> |
| 2088 | 2096 | )} |
| 2089 | 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 | 2099 | {activeProjectScripts && ( |
| 2092 | 2100 | <ProjectScriptsControl |
| 2093 | 2101 | scripts={activeProjectScripts} |
| ... | ... | @@ -2221,6 +2229,7 @@ interface MessagesTimelineProps { |
| 2221 | 2229 | onRevertUserMessage: (messageId: string) => void; |
| 2222 | 2230 | isRevertingCheckpoint: boolean; |
| 2223 | 2231 | onImageExpand: (image: ExpandedImagePreview) => void; |
| 2232 | markdownCwd: string | undefined; | |
| 2224 | 2233 | } |
| 2225 | 2234 | |
| 2226 | 2235 | type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number]; |
| ... | ... | @@ -2256,6 +2265,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2256 | 2265 | onRevertUserMessage, |
| 2257 | 2266 | isRevertingCheckpoint, |
| 2258 | 2267 | onImageExpand, |
| 2268 | markdownCwd, | |
| 2259 | 2269 | }: MessagesTimelineProps) { |
| 2260 | 2270 | const rows = useMemo<TimelineRow[]>(() => { |
| 2261 | 2271 | const nextRows: TimelineRow[] = []; |
| ... | ... | @@ -2448,7 +2458,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2448 | 2458 | </div> |
| 2449 | 2459 | )} |
| 2450 | 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 | 2462 | {row.message.text} |
| 2453 | 2463 | </pre> |
| 2454 | 2464 | )} |
| ... | ... | @@ -2490,7 +2500,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2490 | 2500 | </div> |
| 2491 | 2501 | )} |
| 2492 | 2502 | <div className="min-w-0 px-1 py-0.5"> |
| 2493 | <ChatMarkdown text={messageText} /> | |
| 2503 | <ChatMarkdown text={messageText} cwd={markdownCwd} /> | |
| 2494 | 2504 | {(() => { |
| 2495 | 2505 | const turnSummary = turnDiffSummaryByAssistantMessageId.get( |
| 2496 | 2506 | row.message.id, |
| ... | ... | @@ -2567,7 +2577,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2567 | 2577 | <span className="ml-1 text-muted-foreground/70">(</span> |
| 2568 | 2578 | <span className="text-success">+{stat.additions}</span> |
| 2569 | 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 | 2583 | <span className="text-muted-foreground/70">)</span> |
| 2572 | 2584 | </> |
| 2573 | 2585 | ); |
| ... | ... | @@ -2728,9 +2740,11 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 2728 | 2740 | <Group aria-label="Subscription actions"> |
| 2729 | 2741 | <Button size="xs" variant="outline" onClick={() => openInEditor(lastEditor)}> |
| 2730 | 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 | 2746 | </Button> |
| 2733 | <GroupSeparator className="hidden sm:block" /> | |
| 2747 | <GroupSeparator className="hidden @sm/header-actions:block" /> | |
| 2734 | 2748 | <Menu> |
| 2735 | 2749 | <MenuTrigger render={<Button aria-label="Copy options" size="icon-xs" variant="outline" />}> |
| 2736 | 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 | 75 | } |
| 76 | 76 | |
| 77 | 77 | interface DiffPanelProps { |
| 78 | mode?: "inline" | "sheet"; | |
| 78 | mode?: "inline" | "sheet" | "sidebar"; | |
| 79 | 79 | } |
| 80 | 80 | |
| 81 | 81 | export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; |
| ... | ... | @@ -271,7 +271,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 271 | 271 | |
| 272 | 272 | updateTurnStripScrollState(); |
| 273 | 273 | const onScroll = () => updateTurnStripScrollState(); |
| 274 | ||
| 274 | ||
| 275 | 275 | element.addEventListener("scroll", onScroll, { passive: true }); |
| 276 | 276 | element.addEventListener("wheel", onTurnStripWheel, { passive: false }); |
| 277 | 277 | |
| ... | ... | @@ -297,10 +297,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 297 | 297 | selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); |
| 298 | 298 | }, [selectedTurn?.turnId, selectedTurnId]); |
| 299 | 299 | |
| 300 | const shouldUseDragRegion = isElectron && mode === "inline"; | |
| 300 | const shouldUseDragRegion = isElectron && mode !== "sheet"; | |
| 301 | 301 | |
| 302 | 302 | return ( |
| 303 | <aside | |
| 303 | <div | |
| 304 | 304 | className={cn( |
| 305 | 305 | "flex h-full min-w-0 flex-col bg-card", |
| 306 | 306 | mode === "inline" |
| ... | ... | @@ -308,18 +308,19 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 308 | 308 | : "w-full", |
| 309 | 309 | )} |
| 310 | 310 | > |
| 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]"> | |
| 318 | 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 | 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 | 325 | <button |
| 325 | 326 | type="button" |
| ... | ... | @@ -402,26 +403,27 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 402 | 403 | </button> |
| 403 | 404 | ))} |
| 404 | 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 | 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 | 427 | </div> |
| 426 | 428 | |
| 427 | 429 | {!activeThread ? ( |
| ... | ... | @@ -488,6 +490,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 488 | 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 | 136 | return <InfoIcon className={iconClassName} />; |
| 137 | 137 | } |
| 138 | 138 | |
| 139 | export default function GitActionsControl({ | |
| 140 | api, | |
| 141 | gitCwd, | |
| 142 | activeThreadId, | |
| 143 | }: GitActionsControlProps) { | |
| 139 | export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitActionsControlProps) { | |
| 144 | 140 | const threadToastData = useMemo( |
| 145 | 141 | () => (activeThreadId ? { threadId: activeThreadId } : undefined), |
| 146 | 142 | [activeThreadId], |
| ... | ... | @@ -501,7 +497,9 @@ export default function GitActionsControl({ |
| 501 | 497 | } |
| 502 | 498 | > |
| 503 | 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 | 503 | </PopoverTrigger> |
| 506 | 504 | <PopoverPopup tooltipStyle side="bottom" align="start"> |
| 507 | 505 | {quickActionDisabledReason} |
| ... | ... | @@ -515,10 +513,12 @@ export default function GitActionsControl({ |
| 515 | 513 | onClick={runQuickAction} |
| 516 | 514 | > |
| 517 | 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 | 519 | </Button> |
| 520 | 520 | )} |
| 521 | <GroupSeparator className="hidden sm:block" /> | |
| 521 | <GroupSeparator className="hidden @sm/header-actions:block" /> | |
| 522 | 522 | <Menu |
| 523 | 523 | onOpenChange={(open) => { |
| 524 | 524 | if (open) void invalidateGitQueries(queryClient); |
apps/web/src/components/ProjectScriptsControl.tsx+7-3| ... | ... | @@ -266,9 +266,11 @@ export default function ProjectScriptsControl({ |
| 266 | 266 | title={`Run ${primaryScript.name}`} |
| 267 | 267 | > |
| 268 | 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 | 272 | </Button> |
| 271 | <GroupSeparator className="hidden sm:block" /> | |
| 273 | <GroupSeparator className="hidden @sm/header-actions:block" /> | |
| 272 | 274 | <Menu highlightItemOnHover={false}> |
| 273 | 275 | <MenuTrigger |
| 274 | 276 | render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />} |
| ... | ... | @@ -329,7 +331,9 @@ export default function ProjectScriptsControl({ |
| 329 | 331 | ) : ( |
| 330 | 332 | <Button size="xs" variant="outline" onClick={openAddDialog} title="Add action"> |
| 331 | 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 | 337 | </Button> |
| 334 | 338 | )} |
| 335 | 339 |
apps/web/src/components/Sidebar.tsx-3| ... | ... | @@ -33,7 +33,6 @@ import { |
| 33 | 33 | SidebarMenuSub, |
| 34 | 34 | SidebarMenuSubButton, |
| 35 | 35 | SidebarMenuSubItem, |
| 36 | SidebarRail, | |
| 37 | 36 | SidebarSeparator, |
| 38 | 37 | SidebarTrigger, |
| 39 | 38 | } from "./ui/sidebar"; |
| ... | ... | @@ -710,8 +709,6 @@ export default function Sidebar() { |
| 710 | 709 | </button> |
| 711 | 710 | )} |
| 712 | 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 | 25 | const SIDEBAR_WIDTH_MOBILE = "calc(100vw - var(--spacing(3)))"; |
| 26 | 26 | const SIDEBAR_WIDTH_ICON = "3rem"; |
| 27 | 27 | const SIDEBAR_KEYBOARD_SHORTCUT = "b"; |
| 28 | const SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH = 16 * 16; | |
| 28 | 29 | |
| 29 | 30 | type SidebarContextProps = { |
| 30 | 31 | state: "expanded" | "collapsed"; |
| ... | ... | @@ -36,7 +37,43 @@ type SidebarContextProps = { |
| 36 | 37 | toggleSidebar: () => void; |
| 37 | 38 | }; |
| 38 | 39 | |
| 40 | type 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 | ||
| 55 | type 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 | ||
| 70 | type SidebarInstanceContextProps = { | |
| 71 | resizable: SidebarResolvedResizableOptions | null; | |
| 72 | side: "left" | "right"; | |
| 73 | }; | |
| 74 | ||
| 39 | 75 | const SidebarContext = React.createContext<SidebarContextProps | null>(null); |
| 76 | const SidebarInstanceContext = React.createContext<SidebarInstanceContextProps | null>(null); | |
| 40 | 77 | |
| 41 | 78 | function useSidebar() { |
| 42 | 79 | const context = React.useContext(SidebarContext); |
| ... | ... | @@ -149,6 +186,7 @@ function Sidebar({ |
| 149 | 186 | side = "left", |
| 150 | 187 | variant = "sidebar", |
| 151 | 188 | collapsible = "offcanvas", |
| 189 | resizable = false, | |
| 152 | 190 | className, |
| 153 | 191 | children, |
| 154 | 192 | ...props |
| ... | ... | @@ -156,98 +194,123 @@ function Sidebar({ |
| 156 | 194 | side?: "left" | "right"; |
| 157 | 195 | variant?: "sidebar" | "floating" | "inset"; |
| 158 | 196 | collapsible?: "offcanvas" | "icon" | "none"; |
| 197 | resizable?: boolean | SidebarResizableOptions; | |
| 159 | 198 | }) { |
| 160 | 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 | ); | |
| 161 | 218 | |
| 162 | 219 | if (collapsible === "none") { |
| 163 | 220 | 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 | |
| 181 | 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 | 225 | className, |
| 184 | 226 | )} |
| 185 | data-mobile="true" | |
| 186 | data-sidebar="sidebar" | |
| 187 | 227 | 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} | |
| 195 | 229 | > |
| 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> | |
| 203 | 264 | ); |
| 204 | 265 | } |
| 205 | 266 | |
| 206 | 267 | 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}> | |
| 216 | 269 | <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} | |
| 241 | 276 | > |
| 277 | {/* This is what handles the sidebar gap on desktop */} | |
| 242 | 278 | <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} | |
| 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 | 311 | </div> |
| 249 | 312 | </div> |
| 250 | </div> | |
| 313 | </SidebarInstanceContext.Provider> | |
| 251 | 314 | ); |
| 252 | 315 | } |
| 253 | 316 | |
| ... | ... | @@ -273,12 +336,244 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t |
| 273 | 336 | ); |
| 274 | 337 | } |
| 275 | 338 | |
| 276 | function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { | |
| 277 | const { toggleSidebar } = useSidebar(); | |
| 339 | function clampSidebarWidth(width: number, options: SidebarResolvedResizableOptions): number { | |
| 340 | return Math.max(options.minWidth, Math.min(width, options.maxWidth)); | |
| 341 | } | |
| 342 | ||
| 343 | function 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 | }, []); | |
| 278 | 573 | |
| 279 | 574 | return ( |
| 280 | 575 | <button |
| 281 | aria-label="Toggle Sidebar" | |
| 576 | aria-label={railLabel} | |
| 282 | 577 | className={cn( |
| 283 | 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 | 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 | 585 | )} |
| 291 | 586 | data-sidebar="rail" |
| 292 | 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 | 594 | tabIndex={-1} |
| 295 | title="Toggle Sidebar" | |
| 595 | title={railTitle} | |
| 296 | 596 | type="button" |
| 297 | 597 | {...props} |
| 298 | 598 | /> |
apps/web/src/markdown-links.test.ts created+37| ... | ... | @@ -0,0 +1,37 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { resolveMarkdownFileLinkTarget } from "./markdown-links"; | |
| 4 | ||
| 5 | describe("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 @@ |
| 1 | import { resolvePathLinkTarget } from "./terminal-links"; | |
| 2 | ||
| 3 | const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; | |
| 4 | const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; | |
| 5 | const EXTERNAL_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/; | |
| 6 | const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; | |
| 7 | const RELATIVE_FILE_PATH_PATTERN = | |
| 8 | /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/; | |
| 9 | const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; | |
| 10 | const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; | |
| 11 | const 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 | ||
| 24 | function safeDecode(value: string): string { | |
| 25 | try { | |
| 26 | return decodeURIComponent(value); | |
| 27 | } catch { | |
| 28 | return value; | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | function 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 | ||
| 41 | function 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 | ||
| 60 | function 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 | ||
| 68 | function 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 | ||
| 77 | function 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 | ||
| 84 | function 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 | ||
| 93 | export 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 | 6 | import { useMediaQuery } from "../hooks/useMediaQuery"; |
| 7 | 7 | import { useStore } from "../store"; |
| 8 | 8 | import { Sheet, SheetPopup } from "../components/ui/sheet"; |
| 9 | import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; | |
| 9 | 10 | |
| 10 | 11 | const DiffPanel = lazy(() => import("../components/DiffPanel")); |
| 11 | 12 | const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; |
| 13 | const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width"; | |
| 14 | const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)"; | |
| 15 | const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 28 * 16; | |
| 12 | 16 | |
| 13 | const DiffPanelWrapper = (props: { | |
| 17 | const DiffPanelSheet = (props: { | |
| 14 | 18 | children: ReactNode; |
| 15 | sheet: boolean; | |
| 16 | 19 | diffOpen: boolean; |
| 17 | 20 | onCloseDiff: () => void; |
| 18 | 21 | }) => { |
| 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" | |
| 28 | 36 | > |
| 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 | ||
| 43 | const 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> | |
| 38 | 49 | ); |
| 39 | 50 | } |
| 40 | 51 | |
| 41 | 52 | 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... | |
| 44 | 55 | </aside> |
| 45 | 56 | ); |
| 46 | 57 | }; |
| 47 | 58 | |
| 59 | const 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 | ||
| 48 | 133 | function ChatThreadRouteView() { |
| 49 | 134 | const { state } = useStore(); |
| 50 | 135 | const navigate = useNavigate(); |
| ... | ... | @@ -62,6 +147,16 @@ function ChatThreadRouteView() { |
| 62 | 147 | }, |
| 63 | 148 | }); |
| 64 | 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]); | |
| 65 | 160 | |
| 66 | 161 | useEffect(() => { |
| 67 | 162 | if (!threadId) { |
| ... | ... | @@ -83,25 +178,27 @@ function ChatThreadRouteView() { |
| 83 | 178 | return null; |
| 84 | 179 | } |
| 85 | 180 | |
| 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 | </> | |
| 95 | 189 | ); |
| 190 | } | |
| 96 | 191 | |
| 97 | 192 | return ( |
| 98 | 193 | <> |
| 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" /> | |
| 103 | 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 | 7 | import { Button } from "../components/ui/button"; |
| 8 | 8 | import { Input } from "../components/ui/input"; |
| 9 | 9 | import { Switch } from "../components/ui/switch"; |
| 10 | import { SidebarInset } from "~/components/ui/sidebar"; | |
| 10 | 11 | |
| 11 | 12 | const THEME_OPTIONS = [ |
| 12 | 13 | { |
| ... | ... | @@ -48,209 +49,214 @@ function SettingsRouteView() { |
| 48 | 49 | const codexHomePath = settings.codexHomePath; |
| 49 | 50 | |
| 50 | 51 | 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 | )} | |
| 66 | 61 | |
| 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. | |
| 72 | 68 | </p> |
| 73 | </div> | |
| 69 | </header> | |
| 74 | 70 | |
| 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> | |
| 104 | 78 | |
| 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> | |
| 109 | 108 | |
| 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> | |
| 115 | 111 | </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> | |
| 131 | 113 | |
| 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. | |
| 148 | 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 | 120 | </div> |
| 162 | </div> | |
| 163 | </section> | |
| 164 | 121 | |
| 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> | |
| 172 | 135 | |
| 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 | } | |
| 193 | 163 | > |
| 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> | |
| 208 | 169 | |
| 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> | |
| 216 | 213 | |
| 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. | |
| 222 | 219 | </p> |
| 223 | 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> | |
| 234 | 221 | |
| 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) => | |
| 241 | 232 | updateSettings({ |
| 242 | confirmThreadDelete: defaults.confirmThreadDelete, | |
| 233 | confirmThreadDelete: Boolean(checked), | |
| 243 | 234 | }) |
| 244 | 235 | } |
| 245 | > | |
| 246 | Restore default | |
| 247 | </Button> | |
| 236 | aria-label="Confirm thread deletion" | |
| 237 | /> | |
| 248 | 238 | </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> | |
| 251 | 257 | </div> |
| 252 | 258 | </div> |
| 253 | </div> | |
| 259 | </SidebarInset> | |
| 254 | 260 | ); |
| 255 | 261 | } |
| 256 | 262 |
apps/web/src/routes/_chat.tsx+3-5| ... | ... | @@ -33,11 +33,9 @@ function ChatRouteLayout() { |
| 33 | 33 | > |
| 34 | 34 | <ThreadSidebar /> |
| 35 | 35 | </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> | |
| 41 | 39 | </SidebarProvider> |
| 42 | 40 | ); |
| 43 | 41 | } |
apps/web/src/store.ts+113-121| ... | ... | @@ -8,10 +8,7 @@ import { |
| 8 | 8 | useReducer, |
| 9 | 9 | } from "react"; |
| 10 | 10 | |
| 11 | import { | |
| 12 | type OrchestrationReadModel, | |
| 13 | type OrchestrationSessionStatus, | |
| 14 | } from "@t3tools/contracts"; | |
| 11 | import { type OrchestrationReadModel, type OrchestrationSessionStatus } from "@t3tools/contracts"; | |
| 15 | 12 | import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; |
| 16 | 13 | import { |
| 17 | 14 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| ... | ... | @@ -30,8 +27,7 @@ import { |
| 30 | 27 | |
| 31 | 28 | type Action = |
| 32 | 29 | | { 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 } | |
| 35 | 31 | | { type: "TOGGLE_PROJECT"; projectId: string } |
| 36 | 32 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } |
| 37 | 33 | | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean } |
| ... | ... | @@ -137,10 +133,6 @@ function updateThread( |
| 137 | 133 | return threads.map((t) => (t.id === threadId ? updater(t) : t)); |
| 138 | 134 | } |
| 139 | 135 | |
| 140 | function cloneProjectScripts(scripts: ReadonlyArray<ProjectScript>): ProjectScript[] { | |
| 141 | return scripts.map((script) => ({ ...script })); | |
| 142 | } | |
| 143 | ||
| 144 | 136 | function mapProjectsFromReadModel( |
| 145 | 137 | incoming: OrchestrationReadModel["projects"], |
| 146 | 138 | previous: Project[], |
| ... | ... | @@ -159,7 +151,7 @@ function mapProjectsFromReadModel( |
| 159 | 151 | (persistedExpandedProjectCwds.size > 0 |
| 160 | 152 | ? persistedExpandedProjectCwds.has(project.workspaceRoot) |
| 161 | 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 | 179 | return providerName === "claudeCode" ? "claudeCode" : "codex"; |
| 188 | 180 | } |
| 189 | 181 | |
| 190 | ||
| 191 | 182 | function normalizeTerminalIds(terminalIds: string[]): string[] { |
| 192 | 183 | const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); |
| 193 | 184 | const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT); |
| ... | ... | @@ -208,7 +199,6 @@ function normalizeRunningTerminalIds( |
| 208 | 199 | .filter((id) => id.length > 0 && validTerminalIdSet.has(id)); |
| 209 | 200 | } |
| 210 | 201 | |
| 211 | ||
| 212 | 202 | function normalizeTerminalGroupIds(terminalIds: string[]): string[] { |
| 213 | 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 | 364 | }); |
| 375 | 365 | } |
| 376 | 366 | |
| 377 | ||
| 378 | 367 | // ── Reducer ────────────────────────────────────────────────────────── |
| 379 | 368 | |
| 380 | 369 | export function reducer(state: AppState, action: Action): AppState { |
| ... | ... | @@ -384,94 +373,100 @@ export function reducer(state: AppState, action: Action): AppState { |
| 384 | 373 | action.readModel.projects.filter((project) => project.deletedAt === null), |
| 385 | 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 | 379 | const threads = action.readModel.threads |
| 389 | 380 | .filter((thread) => thread.deletedAt === null) |
| 390 | 381 | .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 | }); | |
| 473 | 469 | }); |
| 474 | }); | |
| 475 | 470 | return { |
| 476 | 471 | ...state, |
| 477 | 472 | projects, |
| ... | ... | @@ -480,24 +475,27 @@ export function reducer(state: AppState, action: Action): AppState { |
| 480 | 475 | }; |
| 481 | 476 | } |
| 482 | 477 | |
| 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); | |
| 497 | 481 | return { |
| 498 | 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 | } | |
| 501 | 499 | |
| 502 | 500 | case "TOGGLE_PROJECT": |
| 503 | 501 | return { |
| ... | ... | @@ -540,10 +538,7 @@ export function reducer(state: AppState, action: Action): AppState { |
| 540 | 538 | threads: updateThread(state.threads, action.threadId, (thread) => { |
| 541 | 539 | const normalizedThread = normalizeThreadTerminals(thread); |
| 542 | 540 | 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) { | |
| 547 | 542 | return normalizedThread; |
| 548 | 543 | } |
| 549 | 544 | const terminalIds = normalizedThread.terminalIds.includes(action.terminalId) |
| ... | ... | @@ -613,10 +608,7 @@ export function reducer(state: AppState, action: Action): AppState { |
| 613 | 608 | threads: updateThread(state.threads, action.threadId, (thread) => { |
| 614 | 609 | const normalizedThread = normalizeThreadTerminals(thread); |
| 615 | 610 | 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) { | |
| 620 | 612 | return normalizedThread; |
| 621 | 613 | } |
| 622 | 614 | const terminalIds = normalizedThread.terminalIds.includes(action.terminalId) |