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