| author | |
| committer | |
| log | be4d26a1b31d96754bdadc2ab7094dfdc9c7bad9 |
| tree | a7f86bfcb2c98319ebe02cf07ca51ff0c96111a7 |
| parent | 5c0961431909f83536790677f46b6a08dadb3434 |
- Replace broad `useStore` state reads with targeted selector/action hooks across chat, diff, sidebar, and routing views
- Remove dispatch-style calls in favor of direct store actions to reduce unnecessary rerenders
- Improve chat UI interactions (composer menu navigation, image preview buttons, modal click targets)
- Add local `react-doctor` agent skill docs under `apps/web/.agents/react-doctor`10 files changed, 471 insertions(+), 339 deletions(-)
apps/web/.agents/react-doctor/AGENTS.md created+15| ... | ... | @@ -0,0 +1,15 @@ |
| 1 | # React Doctor | |
| 2 | ||
| 3 | Run after making React changes to catch issues early. Use when reviewing code, finishing a feature, or fixing bugs in a React project. | |
| 4 | ||
| 5 | Scans your React codebase for security, performance, correctness, and architecture issues. Outputs a 0-100 score with actionable diagnostics. | |
| 6 | ||
| 7 | ## Usage | |
| 8 | ||
| 9 | ```bash | |
| 10 | npx -y react-doctor@latest . --verbose --diff | |
| 11 | ``` | |
| 12 | ||
| 13 | ## Workflow | |
| 14 | ||
| 15 | Run after making changes to catch issues early. Fix errors first, then re-run to verify the score improved. |
apps/web/.agents/react-doctor/SKILL.md created+19| ... | ... | @@ -0,0 +1,19 @@ |
| 1 | --- | |
| 2 | name: react-doctor | |
| 3 | description: Run after making React changes to catch issues early. Use when reviewing code, finishing a feature, or fixing bugs in a React project. | |
| 4 | version: 1.0.0 | |
| 5 | --- | |
| 6 | ||
| 7 | # React Doctor | |
| 8 | ||
| 9 | Scans your React codebase for security, performance, correctness, and architecture issues. Outputs a 0-100 score with actionable diagnostics. | |
| 10 | ||
| 11 | ## Usage | |
| 12 | ||
| 13 | ```bash | |
| 14 | npx -y react-doctor@latest . --verbose --diff | |
| 15 | ``` | |
| 16 | ||
| 17 | ## Workflow | |
| 18 | ||
| 19 | Run after making changes to catch issues early. Fix errors first, then re-run to verify the score improved. |
apps/web/src/components/BranchToolbar.tsx+24-20| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import type { GitBranch, ThreadId } from "@t3tools/contracts"; |
| 2 | 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | import { useEffect, useState } from "react"; | |
| 3 | import { useCallback, useEffect, useState } from "react"; | |
| 4 | 4 | |
| 5 | 5 | import { newCommandId } from "../lib/utils"; |
| 6 | 6 | import { |
| ... | ... | @@ -43,17 +43,26 @@ export default function BranchToolbar({ |
| 43 | 43 | envLocked, |
| 44 | 44 | onComposerFocusRequest, |
| 45 | 45 | }: BranchToolbarProps) { |
| 46 | const { state, dispatch } = useStore(); | |
| 46 | const setThreadBranchAction = useStore((state) => state.setThreadBranch); | |
| 47 | const setThreadErrorAction = useStore((state) => state.setThreadError); | |
| 47 | 48 | const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); |
| 49 | const serverThread = useStore( | |
| 50 | useCallback((state) => state.threads.find((thread) => thread.id === threadId), [threadId]), | |
| 51 | ); | |
| 52 | const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; | |
| 53 | const activeProject = useStore( | |
| 54 | useCallback( | |
| 55 | (state) => | |
| 56 | activeProjectId ? state.projects.find((project) => project.id === activeProjectId) : undefined, | |
| 57 | [activeProjectId], | |
| 58 | ), | |
| 59 | ); | |
| 48 | 60 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); |
| 49 | 61 | const queryClient = useQueryClient(); |
| 50 | 62 | |
| 51 | 63 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); |
| 52 | 64 | const [branchQuery, setBranchQuery] = useState(""); |
| 53 | 65 | |
| 54 | const serverThread = state.threads.find((thread) => thread.id === threadId); | |
| 55 | const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; | |
| 56 | const activeProject = state.projects.find((project) => project.id === activeProjectId); | |
| 57 | 66 | const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); |
| 58 | 67 | const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; |
| 59 | 68 | const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; |
| ... | ... | @@ -114,12 +123,7 @@ export default function BranchToolbar({ |
| 114 | 123 | }); |
| 115 | 124 | } |
| 116 | 125 | if (hasServerThread) { |
| 117 | dispatch({ | |
| 118 | type: "SET_THREAD_BRANCH", | |
| 119 | threadId: activeThreadId, | |
| 120 | branch: syncedBranch, | |
| 121 | worktreePath: null, | |
| 122 | }); | |
| 126 | setThreadBranchAction(activeThreadId, syncedBranch, null); | |
| 123 | 127 | return; |
| 124 | 128 | } |
| 125 | 129 | setDraftThreadContext(threadId, { |
| ... | ... | @@ -132,22 +136,17 @@ export default function BranchToolbar({ |
| 132 | 136 | activeThreadBranch, |
| 133 | 137 | queryBranches, |
| 134 | 138 | effectiveEnvMode, |
| 135 | dispatch, | |
| 139 | setThreadBranchAction, | |
| 136 | 140 | hasServerThread, |
| 137 | 141 | setDraftThreadContext, |
| 138 | 142 | threadId, |
| 139 | 143 | ]); |
| 140 | 144 | |
| 141 | useEffect(() => { | |
| 142 | if (isBranchMenuOpen) return; | |
| 143 | setBranchQuery(""); | |
| 144 | }, [isBranchMenuOpen]); | |
| 145 | ||
| 146 | 145 | // ── Helpers ─────────────────────────────────────────────────────────── |
| 147 | 146 | |
| 148 | 147 | const setThreadError = (error: string | null) => { |
| 149 | 148 | if (!activeThreadId) return; |
| 150 | dispatch({ type: "SET_ERROR", threadId: activeThreadId, error }); | |
| 149 | setThreadErrorAction(activeThreadId, error); | |
| 151 | 150 | }; |
| 152 | 151 | |
| 153 | 152 | const setThreadBranch = (branch: string | null, worktreePath: string | null) => { |
| ... | ... | @@ -176,7 +175,7 @@ export default function BranchToolbar({ |
| 176 | 175 | }); |
| 177 | 176 | } |
| 178 | 177 | if (hasServerThread) { |
| 179 | dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThreadId, branch, worktreePath }); | |
| 178 | setThreadBranchAction(activeThreadId, branch, worktreePath); | |
| 180 | 179 | return; |
| 181 | 180 | } |
| 182 | 181 | setDraftThreadContext(threadId, { |
| ... | ... | @@ -282,7 +281,12 @@ export default function BranchToolbar({ |
| 282 | 281 | <Combobox |
| 283 | 282 | items={branchPickerItems} |
| 284 | 283 | autoHighlight |
| 285 | onOpenChange={(open) => setIsBranchMenuOpen(open)} | |
| 284 | onOpenChange={(open) => { | |
| 285 | setIsBranchMenuOpen(open); | |
| 286 | if (!open) { | |
| 287 | setBranchQuery(""); | |
| 288 | } | |
| 289 | }} | |
| 286 | 290 | open={isBranchMenuOpen} |
| 287 | 291 | value={activeThreadBranch} |
| 288 | 292 | > |
apps/web/src/components/ChatView.tsx+193-185| ... | ... | @@ -101,7 +101,7 @@ import { CursorIcon, Icon } from "./Icons"; |
| 101 | 101 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 102 | 102 | import { Badge } from "./ui/badge"; |
| 103 | 103 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 104 | import { Command, CommandInput, CommandItem, CommandList } from "./ui/command"; | |
| 104 | import { Command, CommandItem, CommandList } from "./ui/command"; | |
| 105 | 105 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; |
| 106 | 106 | import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; |
| 107 | 107 | import { |
| ... | ... | @@ -337,15 +337,12 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { |
| 337 | 337 | kind: "file" | "directory"; |
| 338 | 338 | theme: "light" | "dark"; |
| 339 | 339 | }) { |
| 340 | const [failed, setFailed] = useState(false); | |
| 340 | const [failedIconUrl, setFailedIconUrl] = useState<string | null>(null); | |
| 341 | 341 | const iconUrl = useMemo( |
| 342 | 342 | () => getVscodeIconUrlForEntry(props.pathValue, props.kind, props.theme), |
| 343 | 343 | [props.kind, props.pathValue, props.theme], |
| 344 | 344 | ); |
| 345 | ||
| 346 | useEffect(() => { | |
| 347 | setFailed(false); | |
| 348 | }, [iconUrl]); | |
| 345 | const failed = failedIconUrl === iconUrl; | |
| 349 | 346 | |
| 350 | 347 | if (failed) { |
| 351 | 348 | return props.kind === "directory" ? ( |
| ... | ... | @@ -362,7 +359,7 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { |
| 362 | 359 | aria-hidden="true" |
| 363 | 360 | className="size-4 shrink-0" |
| 364 | 361 | loading="lazy" |
| 365 | onError={() => setFailed(true)} | |
| 362 | onError={() => setFailedIconUrl(iconUrl)} | |
| 366 | 363 | /> |
| 367 | 364 | ); |
| 368 | 365 | }); |
| ... | ... | @@ -411,7 +408,6 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { |
| 411 | 408 | triggerKind: ComposerTriggerKind | null; |
| 412 | 409 | onHighlightedItemChange: (itemId: string | null) => void; |
| 413 | 410 | onSelect: (item: ComposerCommandItem) => void; |
| 414 | commandInputRef: React.RefObject<HTMLInputElement | null>; | |
| 415 | 411 | }) { |
| 416 | 412 | return ( |
| 417 | 413 | <Command |
| ... | ... | @@ -423,9 +419,6 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { |
| 423 | 419 | }} |
| 424 | 420 | > |
| 425 | 421 | <div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs"> |
| 426 | <div className="pointer-events-none absolute h-0 w-0 overflow-hidden opacity-0"> | |
| 427 | <CommandInput autoFocus={false} ref={props.commandInputRef} /> | |
| 428 | </div> | |
| 429 | 422 | <CommandList className="max-h-64"> |
| 430 | 423 | {props.items.map((item) => ( |
| 431 | 424 | <ComposerCommandMenuItem |
| ... | ... | @@ -455,7 +448,17 @@ interface ChatViewProps { |
| 455 | 448 | } |
| 456 | 449 | |
| 457 | 450 | export default function ChatView({ threadId }: ChatViewProps) { |
| 458 | const { state, dispatch } = useStore(); | |
| 451 | const markThreadVisited = useStore((state) => state.markThreadVisited); | |
| 452 | const setThreadTerminalOpen = useStore((state) => state.setThreadTerminalOpen); | |
| 453 | const splitThreadTerminalAction = useStore((state) => state.splitThreadTerminal); | |
| 454 | const newThreadTerminalAction = useStore((state) => state.newThreadTerminal); | |
| 455 | const setThreadActiveTerminal = useStore((state) => state.setThreadActiveTerminal); | |
| 456 | const closeThreadTerminalAction = useStore((state) => state.closeThreadTerminal); | |
| 457 | const setThreadErrorAction = useStore((state) => state.setThreadError); | |
| 458 | const setThreadBranchAction = useStore((state) => state.setThreadBranch); | |
| 459 | const setRuntimeMode = useStore((state) => state.setRuntimeMode); | |
| 460 | const setThreadTerminalHeight = useStore((state) => state.setThreadTerminalHeight); | |
| 461 | const runtimeMode = useStore((state) => state.runtimeMode); | |
| 459 | 462 | const { settings } = useAppSettings(); |
| 460 | 463 | const navigate = useNavigate(); |
| 461 | 464 | const rawSearch = useSearch({ |
| ... | ... | @@ -487,6 +490,19 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 487 | 490 | const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread); |
| 488 | 491 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); |
| 489 | 492 | const draftThread = useComposerDraftStore((store) => store.draftThreadsByThreadId[threadId] ?? null); |
| 493 | const serverThread = useStore( | |
| 494 | useCallback((state) => state.threads.find((thread) => thread.id === threadId), [threadId]), | |
| 495 | ); | |
| 496 | const fallbackDraftProjectId = draftThread?.projectId ?? null; | |
| 497 | const fallbackDraftProject = useStore( | |
| 498 | useCallback( | |
| 499 | (state) => | |
| 500 | fallbackDraftProjectId | |
| 501 | ? state.projects.find((project) => project.id === fallbackDraftProjectId) | |
| 502 | : undefined, | |
| 503 | [fallbackDraftProjectId], | |
| 504 | ), | |
| 505 | ); | |
| 490 | 506 | const promptRef = useRef(prompt); |
| 491 | 507 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); |
| 492 | 508 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| ... | ... | @@ -511,6 +527,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 511 | 527 | Record<string, string> |
| 512 | 528 | >(() => readLastInvokedScriptByProjectFromStorage()); |
| 513 | 529 | const messagesScrollRef = useRef<HTMLDivElement>(null); |
| 530 | const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null); | |
| 514 | 531 | const shouldAutoScrollRef = useRef(true); |
| 515 | 532 | const pendingAutoScrollFrameRef = useRef<number | null>(null); |
| 516 | 533 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| ... | ... | @@ -523,6 +540,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 523 | 540 | const sendInFlightRef = useRef(false); |
| 524 | 541 | const dragDepthRef = useRef(0); |
| 525 | 542 | const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); |
| 543 | const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { | |
| 544 | messagesScrollRef.current = element; | |
| 545 | setMessagesScrollElement(element); | |
| 546 | }, []); | |
| 526 | 547 | |
| 527 | 548 | const setPrompt = useCallback( |
| 528 | 549 | (nextPrompt: string) => { |
| ... | ... | @@ -555,8 +576,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 555 | 576 | [removeComposerDraftImage, threadId], |
| 556 | 577 | ); |
| 557 | 578 | |
| 558 | const serverThread = state.threads.find((t) => t.id === threadId); | |
| 559 | const fallbackDraftProject = state.projects.find((project) => project.id === draftThread?.projectId); | |
| 560 | 579 | const localDraftError = localDraftErrorsByThreadId[threadId] ?? null; |
| 561 | 580 | const localDraftThread = useMemo( |
| 562 | 581 | () => |
| ... | ... | @@ -581,7 +600,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 581 | 600 | const activeThreadId = activeThread?.id ?? null; |
| 582 | 601 | const activeLatestTurn = activeThread?.latestTurn ?? null; |
| 583 | 602 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); |
| 584 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); | |
| 603 | const activeProjectId = activeThread?.projectId ?? null; | |
| 604 | const activeProject = useStore( | |
| 605 | useCallback( | |
| 606 | (state) => | |
| 607 | activeProjectId ? state.projects.find((project) => project.id === activeProjectId) : undefined, | |
| 608 | [activeProjectId], | |
| 609 | ), | |
| 610 | ); | |
| 585 | 611 | |
| 586 | 612 | useEffect(() => { |
| 587 | 613 | if (!serverThread) { |
| ... | ... | @@ -605,16 +631,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 605 | 631 | const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN; |
| 606 | 632 | if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; |
| 607 | 633 | |
| 608 | dispatch({ | |
| 609 | type: "MARK_THREAD_VISITED", | |
| 610 | threadId: activeThread.id, | |
| 611 | }); | |
| 634 | markThreadVisited(activeThread.id); | |
| 612 | 635 | }, [ |
| 613 | 636 | activeThread?.id, |
| 614 | 637 | activeThread?.lastVisitedAt, |
| 615 | 638 | activeLatestTurn?.completedAt, |
| 616 | 639 | latestTurnSettled, |
| 617 | dispatch, | |
| 640 | markThreadVisited, | |
| 618 | 641 | ]); |
| 619 | 642 | |
| 620 | 643 | const baseThreadModel = resolveModelSlug(activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL); |
| ... | ... | @@ -981,41 +1004,25 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 981 | 1004 | }, [focusComposer]); |
| 982 | 1005 | const toggleTerminalVisibility = useCallback(() => { |
| 983 | 1006 | if (!activeThreadId) return; |
| 984 | dispatch({ | |
| 985 | type: "SET_THREAD_TERMINAL_OPEN", | |
| 986 | threadId: activeThreadId, | |
| 987 | open: !activeThread?.terminalOpen, | |
| 988 | }); | |
| 989 | }, [activeThread?.terminalOpen, activeThreadId, dispatch]); | |
| 1007 | setThreadTerminalOpen(activeThreadId, !activeThread?.terminalOpen); | |
| 1008 | }, [activeThread?.terminalOpen, activeThreadId, setThreadTerminalOpen]); | |
| 990 | 1009 | const splitTerminal = useCallback(() => { |
| 991 | 1010 | if (!activeThreadId || hasReachedTerminalLimit) return; |
| 992 | dispatch({ | |
| 993 | type: "SPLIT_THREAD_TERMINAL", | |
| 994 | threadId: activeThreadId, | |
| 995 | terminalId: `terminal-${crypto.randomUUID()}`, | |
| 996 | }); | |
| 1011 | splitThreadTerminalAction(activeThreadId, `terminal-${crypto.randomUUID()}`); | |
| 997 | 1012 | setTerminalFocusRequestId((value) => value + 1); |
| 998 | }, [activeThreadId, dispatch, hasReachedTerminalLimit]); | |
| 1013 | }, [activeThreadId, hasReachedTerminalLimit, splitThreadTerminalAction]); | |
| 999 | 1014 | const createNewTerminal = useCallback(() => { |
| 1000 | 1015 | if (!activeThreadId || hasReachedTerminalLimit) return; |
| 1001 | dispatch({ | |
| 1002 | type: "NEW_THREAD_TERMINAL", | |
| 1003 | threadId: activeThreadId, | |
| 1004 | terminalId: `terminal-${crypto.randomUUID()}`, | |
| 1005 | }); | |
| 1016 | newThreadTerminalAction(activeThreadId, `terminal-${crypto.randomUUID()}`); | |
| 1006 | 1017 | setTerminalFocusRequestId((value) => value + 1); |
| 1007 | }, [activeThreadId, dispatch, hasReachedTerminalLimit]); | |
| 1018 | }, [activeThreadId, hasReachedTerminalLimit, newThreadTerminalAction]); | |
| 1008 | 1019 | const activateTerminal = useCallback( |
| 1009 | 1020 | (terminalId: string) => { |
| 1010 | 1021 | if (!activeThreadId) return; |
| 1011 | dispatch({ | |
| 1012 | type: "SET_THREAD_ACTIVE_TERMINAL", | |
| 1013 | threadId: activeThreadId, | |
| 1014 | terminalId, | |
| 1015 | }); | |
| 1022 | setThreadActiveTerminal(activeThreadId, terminalId); | |
| 1016 | 1023 | setTerminalFocusRequestId((value) => value + 1); |
| 1017 | 1024 | }, |
| 1018 | [activeThreadId, dispatch], | |
| 1025 | [activeThreadId, setThreadActiveTerminal], | |
| 1019 | 1026 | ); |
| 1020 | 1027 | const closeTerminal = useCallback( |
| 1021 | 1028 | (terminalId: string) => { |
| ... | ... | @@ -1038,14 +1045,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1038 | 1045 | } else { |
| 1039 | 1046 | void fallbackExitWrite(); |
| 1040 | 1047 | } |
| 1041 | dispatch({ | |
| 1042 | type: "CLOSE_THREAD_TERMINAL", | |
| 1043 | threadId: activeThreadId, | |
| 1044 | terminalId, | |
| 1045 | }); | |
| 1048 | closeThreadTerminalAction(activeThreadId, terminalId); | |
| 1046 | 1049 | setTerminalFocusRequestId((value) => value + 1); |
| 1047 | 1050 | }, |
| 1048 | [activeThread?.terminalIds.length, activeThreadId, dispatch], | |
| 1051 | [activeThread?.terminalIds.length, activeThreadId, closeThreadTerminalAction], | |
| 1049 | 1052 | ); |
| 1050 | 1053 | const runProjectScript = useCallback( |
| 1051 | 1054 | async ( |
| ... | ... | @@ -1079,23 +1082,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1079 | 1082 | ? `terminal-${crypto.randomUUID()}` |
| 1080 | 1083 | : baseTerminalId; |
| 1081 | 1084 | |
| 1082 | dispatch({ | |
| 1083 | type: "SET_THREAD_TERMINAL_OPEN", | |
| 1084 | threadId: activeThreadId, | |
| 1085 | open: true, | |
| 1086 | }); | |
| 1085 | setThreadTerminalOpen(activeThreadId, true); | |
| 1087 | 1086 | if (shouldCreateNewTerminal) { |
| 1088 | dispatch({ | |
| 1089 | type: "NEW_THREAD_TERMINAL", | |
| 1090 | threadId: activeThreadId, | |
| 1091 | terminalId: targetTerminalId, | |
| 1092 | }); | |
| 1087 | newThreadTerminalAction(activeThreadId, targetTerminalId); | |
| 1093 | 1088 | } else { |
| 1094 | dispatch({ | |
| 1095 | type: "SET_THREAD_ACTIVE_TERMINAL", | |
| 1096 | threadId: activeThreadId, | |
| 1097 | terminalId: targetTerminalId, | |
| 1098 | }); | |
| 1089 | setThreadActiveTerminal(activeThreadId, targetTerminalId); | |
| 1099 | 1090 | } |
| 1100 | 1091 | setTerminalFocusRequestId((value) => value + 1); |
| 1101 | 1092 | |
| ... | ... | @@ -1126,14 +1117,23 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1126 | 1117 | data: `${script.command}\r`, |
| 1127 | 1118 | }); |
| 1128 | 1119 | } catch (error) { |
| 1129 | dispatch({ | |
| 1130 | type: "SET_ERROR", | |
| 1131 | threadId: activeThreadId, | |
| 1132 | error: error instanceof Error ? error.message : `Failed to run script "${script.name}".`, | |
| 1133 | }); | |
| 1120 | setThreadErrorAction( | |
| 1121 | activeThreadId, | |
| 1122 | error instanceof Error ? error.message : `Failed to run script "${script.name}".`, | |
| 1123 | ); | |
| 1134 | 1124 | } |
| 1135 | 1125 | }, |
| 1136 | [activeProject, activeThread, activeThreadId, dispatch, gitCwd, isServerThread], | |
| 1126 | [ | |
| 1127 | activeProject, | |
| 1128 | activeThread, | |
| 1129 | activeThreadId, | |
| 1130 | gitCwd, | |
| 1131 | isServerThread, | |
| 1132 | newThreadTerminalAction, | |
| 1133 | setThreadActiveTerminal, | |
| 1134 | setThreadErrorAction, | |
| 1135 | setThreadTerminalOpen, | |
| 1136 | ], | |
| 1137 | 1137 | ); |
| 1138 | 1138 | const persistProjectScripts = useCallback( |
| 1139 | 1139 | async (input: { |
| ... | ... | @@ -1236,35 +1236,34 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1236 | 1236 | ); |
| 1237 | 1237 | |
| 1238 | 1238 | const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { |
| 1239 | if (mode === state.runtimeMode) return; | |
| 1240 | dispatch({ type: "SET_RUNTIME_MODE", mode }); | |
| 1239 | if (mode === runtimeMode) return; | |
| 1240 | setRuntimeMode(mode); | |
| 1241 | 1241 | scheduleComposerFocus(); |
| 1242 | 1242 | const api = readNativeApi(); |
| 1243 | 1243 | if (!api) return; |
| 1244 | 1244 | |
| 1245 | const runningThreadIds = state.threads | |
| 1245 | const runningThreadIds = useStore | |
| 1246 | .getState() | |
| 1247 | .threads | |
| 1246 | 1248 | .filter((thread) => thread.session !== null && thread.session.status !== "closed") |
| 1247 | 1249 | .map((thread) => thread.id); |
| 1248 | 1250 | |
| 1249 | 1251 | if (runningThreadIds.length === 0) return; |
| 1250 | 1252 | |
| 1251 | 1253 | setIsSwitchingRuntimeMode(true); |
| 1252 | try { | |
| 1253 | await Promise.all( | |
| 1254 | runningThreadIds.map((threadId) => | |
| 1255 | api.orchestration | |
| 1256 | .dispatchCommand({ | |
| 1257 | type: "thread.session.stop", | |
| 1258 | commandId: newCommandId(), | |
| 1259 | threadId, | |
| 1260 | createdAt: new Date().toISOString(), | |
| 1261 | }) | |
| 1262 | .catch(() => undefined), | |
| 1263 | ), | |
| 1264 | ); | |
| 1265 | } finally { | |
| 1266 | setIsSwitchingRuntimeMode(false); | |
| 1267 | } | |
| 1254 | await Promise.all( | |
| 1255 | runningThreadIds.map((threadId) => | |
| 1256 | api.orchestration | |
| 1257 | .dispatchCommand({ | |
| 1258 | type: "thread.session.stop", | |
| 1259 | commandId: newCommandId(), | |
| 1260 | threadId, | |
| 1261 | createdAt: new Date().toISOString(), | |
| 1262 | }) | |
| 1263 | .catch(() => undefined), | |
| 1264 | ), | |
| 1265 | ); | |
| 1266 | setIsSwitchingRuntimeMode(false); | |
| 1268 | 1267 | }; |
| 1269 | 1268 | |
| 1270 | 1269 | useEffect(() => { |
| ... | ... | @@ -1642,11 +1641,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1642 | 1641 | event.preventDefault(); |
| 1643 | 1642 | event.stopPropagation(); |
| 1644 | 1643 | if (!activeThread?.terminalOpen) { |
| 1645 | dispatch({ | |
| 1646 | type: "SET_THREAD_TERMINAL_OPEN", | |
| 1647 | threadId: activeThreadId, | |
| 1648 | open: true, | |
| 1649 | }); | |
| 1644 | setThreadTerminalOpen(activeThreadId, true); | |
| 1650 | 1645 | } |
| 1651 | 1646 | splitTerminal(); |
| 1652 | 1647 | return; |
| ... | ... | @@ -1664,11 +1659,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1664 | 1659 | event.preventDefault(); |
| 1665 | 1660 | event.stopPropagation(); |
| 1666 | 1661 | if (!activeThread?.terminalOpen) { |
| 1667 | dispatch({ | |
| 1668 | type: "SET_THREAD_TERMINAL_OPEN", | |
| 1669 | threadId: activeThreadId, | |
| 1670 | open: true, | |
| 1671 | }); | |
| 1662 | setThreadTerminalOpen(activeThreadId, true); | |
| 1672 | 1663 | } |
| 1673 | 1664 | createNewTerminal(); |
| 1674 | 1665 | return; |
| ... | ... | @@ -1698,23 +1689,19 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1698 | 1689 | activeThreadId, |
| 1699 | 1690 | closeTerminal, |
| 1700 | 1691 | createNewTerminal, |
| 1701 | dispatch, | |
| 1702 | 1692 | runProjectScript, |
| 1703 | 1693 | splitTerminal, |
| 1704 | 1694 | keybindings, |
| 1705 | 1695 | onToggleDiff, |
| 1696 | setThreadTerminalOpen, | |
| 1706 | 1697 | toggleTerminalVisibility, |
| 1707 | 1698 | ]); |
| 1708 | 1699 | |
| 1709 | 1700 | const setThreadError = useCallback( |
| 1710 | 1701 | (threadId: ThreadId | null, error: string | null) => { |
| 1711 | 1702 | if (!threadId) return; |
| 1712 | if (state.threads.some((thread) => thread.id === threadId)) { | |
| 1713 | dispatch({ | |
| 1714 | type: "SET_ERROR", | |
| 1715 | threadId, | |
| 1716 | error, | |
| 1717 | }); | |
| 1703 | if (useStore.getState().threads.some((thread) => thread.id === threadId)) { | |
| 1704 | setThreadErrorAction(threadId, error); | |
| 1718 | 1705 | return; |
| 1719 | 1706 | } |
| 1720 | 1707 | setLocalDraftErrorsByThreadId((existing) => { |
| ... | ... | @@ -1727,7 +1714,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1727 | 1714 | }; |
| 1728 | 1715 | }); |
| 1729 | 1716 | }, |
| 1730 | [dispatch, state.threads], | |
| 1717 | [setThreadErrorAction], | |
| 1731 | 1718 | ); |
| 1732 | 1719 | |
| 1733 | 1720 | const addComposerImages = (files: File[]) => { |
| ... | ... | @@ -1868,9 +1855,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1868 | 1855 | activeThread.id, |
| 1869 | 1856 | err instanceof Error ? err.message : "Failed to revert thread state.", |
| 1870 | 1857 | ); |
| 1871 | } finally { | |
| 1872 | setIsRevertingCheckpoint(false); | |
| 1873 | 1858 | } |
| 1859 | setIsRevertingCheckpoint(false); | |
| 1874 | 1860 | }, |
| 1875 | 1861 | [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError], |
| 1876 | 1862 | ); |
| ... | ... | @@ -1894,11 +1880,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1894 | 1880 | const shouldCreateWorktree = |
| 1895 | 1881 | isFirstMessage && envMode === "worktree" && !activeThread.worktreePath; |
| 1896 | 1882 | if (shouldCreateWorktree && !activeThread.branch) { |
| 1897 | dispatch({ | |
| 1898 | type: "SET_ERROR", | |
| 1899 | threadId: threadIdForSend, | |
| 1900 | error: "Select a base branch before sending in New worktree mode.", | |
| 1901 | }); | |
| 1883 | setThreadErrorAction( | |
| 1884 | threadIdForSend, | |
| 1885 | "Select a base branch before sending in New worktree mode.", | |
| 1886 | ); | |
| 1902 | 1887 | return; |
| 1903 | 1888 | } |
| 1904 | 1889 | |
| ... | ... | @@ -1971,12 +1956,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1971 | 1956 | }); |
| 1972 | 1957 | // Keep local thread state in sync immediately so terminal drawer opens |
| 1973 | 1958 | // with the worktree cwd/env instead of briefly using the project root. |
| 1974 | dispatch({ | |
| 1975 | type: "SET_THREAD_BRANCH", | |
| 1976 | threadId: threadIdForSend, | |
| 1977 | branch: result.worktree.branch, | |
| 1978 | worktreePath: result.worktree.path, | |
| 1979 | }); | |
| 1959 | setThreadBranchAction( | |
| 1960 | threadIdForSend, | |
| 1961 | result.worktree.branch, | |
| 1962 | result.worktree.path, | |
| 1963 | ); | |
| 1980 | 1964 | } |
| 1981 | 1965 | } |
| 1982 | 1966 | |
| ... | ... | @@ -2026,9 +2010,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2026 | 2010 | |
| 2027 | 2011 | setSendPhase("sending-turn"); |
| 2028 | 2012 | const turnAttachments = await turnAttachmentsPromise; |
| 2029 | const approvalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request"; | |
| 2030 | const sandboxMode = | |
| 2031 | state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; | |
| 2013 | const approvalPolicy = runtimeMode === "full-access" ? "never" : "on-request"; | |
| 2014 | const sandboxMode = runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; | |
| 2032 | 2015 | await api.orchestration.dispatchCommand({ |
| 2033 | 2016 | type: "thread.turn.start", |
| 2034 | 2017 | commandId: newCommandId(), |
| ... | ... | @@ -2082,10 +2065,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2082 | 2065 | threadIdForSend, |
| 2083 | 2066 | err instanceof Error ? err.message : "Failed to send message.", |
| 2084 | 2067 | ); |
| 2085 | } finally { | |
| 2086 | sendInFlightRef.current = false; | |
| 2087 | setSendPhase("idle"); | |
| 2088 | 2068 | } |
| 2069 | sendInFlightRef.current = false; | |
| 2070 | setSendPhase("idle"); | |
| 2089 | 2071 | }; |
| 2090 | 2072 | |
| 2091 | 2073 | const onInterrupt = async () => { |
| ... | ... | @@ -2117,16 +2099,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2117 | 2099 | createdAt: new Date().toISOString(), |
| 2118 | 2100 | }); |
| 2119 | 2101 | } catch (err) { |
| 2120 | dispatch({ | |
| 2121 | type: "SET_ERROR", | |
| 2122 | threadId: activeThreadId, | |
| 2123 | error: err instanceof Error ? err.message : "Failed to submit approval decision.", | |
| 2124 | }); | |
| 2125 | } finally { | |
| 2126 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); | |
| 2102 | setThreadErrorAction( | |
| 2103 | activeThreadId, | |
| 2104 | err instanceof Error ? err.message : "Failed to submit approval decision.", | |
| 2105 | ); | |
| 2127 | 2106 | } |
| 2107 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); | |
| 2128 | 2108 | }, |
| 2129 | [activeThreadId, dispatch], | |
| 2109 | [activeThreadId, setThreadErrorAction], | |
| 2130 | 2110 | ); |
| 2131 | 2111 | |
| 2132 | 2112 | const onModelSelect = useCallback( |
| ... | ... | @@ -2211,13 +2191,24 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2211 | 2191 | const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => { |
| 2212 | 2192 | setComposerHighlightedItemId(itemId); |
| 2213 | 2193 | }, []); |
| 2214 | const nudgeComposerMenuHighlight = useCallback((key: "ArrowDown" | "ArrowUp") => { | |
| 2215 | const commandInput = composerCommandInputRef.current; | |
| 2216 | if (!commandInput) return; | |
| 2217 | commandInput.dispatchEvent( | |
| 2218 | new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }), | |
| 2219 | ); | |
| 2220 | }, []); | |
| 2194 | const nudgeComposerMenuHighlight = useCallback( | |
| 2195 | (key: "ArrowDown" | "ArrowUp") => { | |
| 2196 | if (composerMenuItems.length === 0) { | |
| 2197 | return; | |
| 2198 | } | |
| 2199 | const highlightedIndex = composerMenuItems.findIndex( | |
| 2200 | (item) => item.id === composerHighlightedItemId, | |
| 2201 | ); | |
| 2202 | const normalizedIndex = | |
| 2203 | highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0; | |
| 2204 | const offset = key === "ArrowDown" ? 1 : -1; | |
| 2205 | const nextIndex = | |
| 2206 | (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length; | |
| 2207 | const nextItem = composerMenuItems[nextIndex]; | |
| 2208 | setComposerHighlightedItemId(nextItem?.id ?? null); | |
| 2209 | }, | |
| 2210 | [composerHighlightedItemId, composerMenuItems], | |
| 2211 | ); | |
| 2221 | 2212 | const isComposerMenuLoading = |
| 2222 | 2213 | composerTriggerKind === "path" && |
| 2223 | 2214 | ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || |
| ... | ... | @@ -2359,7 +2350,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2359 | 2350 | |
| 2360 | 2351 | {/* Messages */} |
| 2361 | 2352 | <div |
| 2362 | ref={messagesScrollRef} | |
| 2353 | ref={setMessagesScrollContainerRef} | |
| 2363 | 2354 | className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain px-3 py-3 sm:px-5 sm:py-4" |
| 2364 | 2355 | onScroll={onMessagesScroll} |
| 2365 | 2356 | > |
| ... | ... | @@ -2369,7 +2360,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2369 | 2360 | isWorking={isWorking} |
| 2370 | 2361 | activeTurnInProgress={!latestTurnSettled} |
| 2371 | 2362 | activeTurnStartedAt={activeLatestTurn?.startedAt ?? null} |
| 2372 | scrollContainerRef={messagesScrollRef} | |
| 2363 | scrollContainer={messagesScrollElement} | |
| 2373 | 2364 | timelineEntries={timelineEntries} |
| 2374 | 2365 | completionDividerBeforeEntryId={completionDividerBeforeEntryId} |
| 2375 | 2366 | completionSummary={completionSummary} |
| ... | ... | @@ -2414,7 +2405,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2414 | 2405 | triggerKind={composerTriggerKind} |
| 2415 | 2406 | onHighlightedItemChange={onComposerMenuItemHighlighted} |
| 2416 | 2407 | onSelect={onSelectComposerItem} |
| 2417 | commandInputRef={composerCommandInputRef} | |
| 2418 | 2408 | /> |
| 2419 | 2409 | </div> |
| 2420 | 2410 | )} |
| ... | ... | @@ -2427,16 +2417,22 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2427 | 2417 | className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background" |
| 2428 | 2418 | > |
| 2429 | 2419 | {image.previewUrl ? ( |
| 2430 | <img | |
| 2431 | src={image.previewUrl} | |
| 2432 | alt={image.name} | |
| 2433 | className="h-full w-full cursor-zoom-in object-cover" | |
| 2420 | <button | |
| 2421 | type="button" | |
| 2422 | className="h-full w-full cursor-zoom-in" | |
| 2423 | aria-label={`Preview ${image.name}`} | |
| 2434 | 2424 | onClick={() => { |
| 2435 | 2425 | const preview = buildExpandedImagePreview(composerImages, image.id); |
| 2436 | 2426 | if (!preview) return; |
| 2437 | 2427 | setExpandedImage(preview); |
| 2438 | 2428 | }} |
| 2439 | /> | |
| 2429 | > | |
| 2430 | <img | |
| 2431 | src={image.previewUrl} | |
| 2432 | alt={image.name} | |
| 2433 | className="h-full w-full object-cover" | |
| 2434 | /> | |
| 2435 | </button> | |
| 2440 | 2436 | ) : ( |
| 2441 | 2437 | <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70"> |
| 2442 | 2438 | {image.name} |
| ... | ... | @@ -2538,18 +2534,18 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2538 | 2534 | disabled={isSwitchingRuntimeMode} |
| 2539 | 2535 | onClick={() => |
| 2540 | 2536 | void handleRuntimeModeChange( |
| 2541 | state.runtimeMode === "full-access" ? "approval-required" : "full-access", | |
| 2537 | runtimeMode === "full-access" ? "approval-required" : "full-access", | |
| 2542 | 2538 | ) |
| 2543 | 2539 | } |
| 2544 | 2540 | title={ |
| 2545 | state.runtimeMode === "full-access" | |
| 2541 | runtimeMode === "full-access" | |
| 2546 | 2542 | ? "Full access — click to require approvals" |
| 2547 | 2543 | : "Approval required — click for full access" |
| 2548 | 2544 | } |
| 2549 | 2545 | > |
| 2550 | {state.runtimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />} | |
| 2546 | {runtimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />} | |
| 2551 | 2547 | <span className="sr-only sm:not-sr-only"> |
| 2552 | {state.runtimeMode === "full-access" ? "Full access" : "Supervised"} | |
| 2548 | {runtimeMode === "full-access" ? "Full access" : "Supervised"} | |
| 2553 | 2549 | </span> |
| 2554 | 2550 | </Button> |
| 2555 | 2551 | </div> |
| ... | ... | @@ -2670,13 +2666,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2670 | 2666 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} |
| 2671 | 2667 | onActiveTerminalChange={activateTerminal} |
| 2672 | 2668 | onCloseTerminal={closeTerminal} |
| 2673 | onHeightChange={(height) => | |
| 2674 | dispatch({ | |
| 2675 | type: "SET_THREAD_TERMINAL_HEIGHT", | |
| 2676 | threadId: activeThread.id, | |
| 2677 | height, | |
| 2678 | }) | |
| 2679 | } | |
| 2669 | onHeightChange={(height) => setThreadTerminalHeight(activeThread.id, height)} | |
| 2680 | 2670 | /> |
| 2681 | 2671 | ); |
| 2682 | 2672 | })()} |
| ... | ... | @@ -2687,27 +2677,28 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2687 | 2677 | role="dialog" |
| 2688 | 2678 | aria-modal="true" |
| 2689 | 2679 | aria-label="Expanded image preview" |
| 2690 | onClick={closeExpandedImage} | |
| 2691 | 2680 | > |
| 2681 | <button | |
| 2682 | type="button" | |
| 2683 | className="absolute inset-0 z-0 cursor-zoom-out" | |
| 2684 | aria-label="Close image preview" | |
| 2685 | onClick={closeExpandedImage} | |
| 2686 | /> | |
| 2692 | 2687 | {expandedImage.images.length > 1 && ( |
| 2693 | 2688 | <Button |
| 2694 | 2689 | type="button" |
| 2695 | 2690 | size="icon" |
| 2696 | 2691 | variant="ghost" |
| 2697 | className="absolute left-2 top-1/2 z-10 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:left-6" | |
| 2692 | className="absolute left-2 top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:left-6" | |
| 2698 | 2693 | aria-label="Previous image" |
| 2699 | onClick={(event) => { | |
| 2700 | event.stopPropagation(); | |
| 2694 | onClick={() => { | |
| 2701 | 2695 | navigateExpandedImage(-1); |
| 2702 | 2696 | }} |
| 2703 | 2697 | > |
| 2704 | 2698 | <ChevronLeftIcon className="size-5" /> |
| 2705 | 2699 | </Button> |
| 2706 | 2700 | )} |
| 2707 | <div | |
| 2708 | className="relative isolate max-h-[92vh] max-w-[92vw]" | |
| 2709 | onClick={(event) => event.stopPropagation()} | |
| 2710 | > | |
| 2701 | <div className="relative isolate z-10 max-h-[92vh] max-w-[92vw]"> | |
| 2711 | 2702 | <Button |
| 2712 | 2703 | type="button" |
| 2713 | 2704 | size="icon-xs" |
| ... | ... | @@ -2736,10 +2727,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2736 | 2727 | type="button" |
| 2737 | 2728 | size="icon" |
| 2738 | 2729 | variant="ghost" |
| 2739 | className="absolute right-2 top-1/2 z-10 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:right-6" | |
| 2730 | className="absolute right-2 top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:right-6" | |
| 2740 | 2731 | aria-label="Next image" |
| 2741 | onClick={(event) => { | |
| 2742 | event.stopPropagation(); | |
| 2732 | onClick={() => { | |
| 2743 | 2733 | navigateExpandedImage(1); |
| 2744 | 2734 | }} |
| 2745 | 2735 | > |
| ... | ... | @@ -2950,7 +2940,7 @@ interface MessagesTimelineProps { |
| 2950 | 2940 | isWorking: boolean; |
| 2951 | 2941 | activeTurnInProgress: boolean; |
| 2952 | 2942 | activeTurnStartedAt: string | null; |
| 2953 | scrollContainerRef: React.RefObject<HTMLDivElement | null>; | |
| 2943 | scrollContainer: HTMLDivElement | null; | |
| 2954 | 2944 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; |
| 2955 | 2945 | completionDividerBeforeEntryId: string | null; |
| 2956 | 2946 | completionSummary: string | null; |
| ... | ... | @@ -3004,7 +2994,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 3004 | 2994 | isWorking, |
| 3005 | 2995 | activeTurnInProgress, |
| 3006 | 2996 | activeTurnStartedAt, |
| 3007 | scrollContainerRef, | |
| 2997 | scrollContainer, | |
| 3008 | 2998 | timelineEntries, |
| 3009 | 2999 | completionDividerBeforeEntryId, |
| 3010 | 3000 | completionSummary, |
| ... | ... | @@ -3114,7 +3104,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 3114 | 3104 | |
| 3115 | 3105 | const rowVirtualizer = useVirtualizer({ |
| 3116 | 3106 | count: virtualizedRowCount, |
| 3117 | getScrollElement: () => scrollContainerRef.current, | |
| 3107 | getScrollElement: () => scrollContainer, | |
| 3118 | 3108 | // Use stable row ids so virtual measurements do not leak across thread switches. |
| 3119 | 3109 | getItemKey: (index: number) => rows[index]?.id ?? index, |
| 3120 | 3110 | estimateSize: (index: number) => { |
| ... | ... | @@ -3242,18 +3232,24 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 3242 | 3232 | className="overflow-hidden rounded-lg border border-border/80 bg-background/70" |
| 3243 | 3233 | > |
| 3244 | 3234 | {image.previewUrl ? ( |
| 3245 | <img | |
| 3246 | src={image.previewUrl} | |
| 3247 | alt={image.name} | |
| 3248 | className="h-full max-h-[220px] w-full cursor-zoom-in object-cover" | |
| 3249 | onLoad={onTimelineImageLoad} | |
| 3250 | onError={onTimelineImageLoad} | |
| 3235 | <button | |
| 3236 | type="button" | |
| 3237 | className="h-full w-full cursor-zoom-in" | |
| 3238 | aria-label={`Preview ${image.name}`} | |
| 3251 | 3239 | onClick={() => { |
| 3252 | 3240 | const preview = buildExpandedImagePreview(userImages, image.id); |
| 3253 | 3241 | if (!preview) return; |
| 3254 | 3242 | onImageExpand(preview); |
| 3255 | 3243 | }} |
| 3256 | /> | |
| 3244 | > | |
| 3245 | <img | |
| 3246 | src={image.previewUrl} | |
| 3247 | alt={image.name} | |
| 3248 | className="h-full max-h-[220px] w-full object-cover" | |
| 3249 | onLoad={onTimelineImageLoad} | |
| 3250 | onError={onTimelineImageLoad} | |
| 3251 | /> | |
| 3252 | </button> | |
| 3257 | 3253 | ) : ( |
| 3258 | 3254 | <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70"> |
| 3259 | 3255 | {image.name} |
| ... | ... | @@ -3545,9 +3541,21 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 3545 | 3541 | ] satisfies { label: string; Icon: Icon; value: EditorId }[]; |
| 3546 | 3542 | const primaryOption = options.find(({ value }) => value === lastEditor); |
| 3547 | 3543 | |
| 3548 | const { state } = useStore(); | |
| 3549 | const activeThread = state.threads.find((t) => t.id === activeThreadId); | |
| 3550 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); | |
| 3544 | const activeThread = useStore( | |
| 3545 | useCallback( | |
| 3546 | (state) => | |
| 3547 | activeThreadId ? state.threads.find((thread) => thread.id === activeThreadId) : undefined, | |
| 3548 | [activeThreadId], | |
| 3549 | ), | |
| 3550 | ); | |
| 3551 | const activeProjectId = activeThread?.projectId ?? null; | |
| 3552 | const activeProject = useStore( | |
| 3553 | useCallback( | |
| 3554 | (state) => | |
| 3555 | activeProjectId ? state.projects.find((project) => project.id === activeProjectId) : undefined, | |
| 3556 | [activeProjectId], | |
| 3557 | ), | |
| 3558 | ); | |
| 3551 | 3559 | |
| 3552 | 3560 | const openInEditor = useCallback( |
| 3553 | 3561 | (editorId: EditorId | null) => { |
apps/web/src/components/DiffPanel.tsx+29-11| ... | ... | @@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query"; |
| 4 | 4 | import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; |
| 5 | 5 | import { ThreadId, type TurnId } from "@t3tools/contracts"; |
| 6 | 6 | import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; |
| 7 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 7 | import { type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 8 | 8 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| 9 | 9 | import { cn } from "~/lib/utils"; |
| 10 | 10 | import { readNativeApi } from "../nativeApi"; |
| ... | ... | @@ -156,7 +156,6 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; |
| 156 | 156 | export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 157 | 157 | const navigate = useNavigate(); |
| 158 | 158 | const { resolvedTheme } = useTheme(); |
| 159 | const { state } = useStore(); | |
| 160 | 159 | const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked"); |
| 161 | 160 | const patchViewportRef = useRef<HTMLDivElement>(null); |
| 162 | 161 | const turnStripRef = useRef<HTMLDivElement>(null); |
| ... | ... | @@ -168,8 +167,21 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 168 | 167 | }); |
| 169 | 168 | const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); |
| 170 | 169 | const activeThreadId = routeThreadId; |
| 171 | const activeThread = state.threads.find((thread) => thread.id === activeThreadId); | |
| 172 | const activeProject = state.projects.find((project) => project.id === activeThread?.projectId); | |
| 170 | const activeThread = useStore( | |
| 171 | useCallback( | |
| 172 | (state) => | |
| 173 | activeThreadId ? state.threads.find((thread) => thread.id === activeThreadId) : undefined, | |
| 174 | [activeThreadId], | |
| 175 | ), | |
| 176 | ); | |
| 177 | const activeProjectId = activeThread?.projectId ?? null; | |
| 178 | const activeProject = useStore( | |
| 179 | useCallback( | |
| 180 | (state) => | |
| 181 | activeProjectId ? state.projects.find((project) => project.id === activeProjectId) : undefined, | |
| 182 | [activeProjectId], | |
| 183 | ), | |
| 184 | ); | |
| 173 | 185 | const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; |
| 174 | 186 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 175 | 187 | useTurnDiffSummaries(activeThread); |
| ... | ... | @@ -342,7 +354,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 342 | 354 | if (!element) return; |
| 343 | 355 | element.scrollBy({ left: offset, behavior: "smooth" }); |
| 344 | 356 | }, []); |
| 345 | const onTurnStripWheel = useCallback((event: WheelEvent) => { | |
| 357 | const onTurnStripWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => { | |
| 346 | 358 | const element = turnStripRef.current; |
| 347 | 359 | if (!element) return; |
| 348 | 360 | if (element.scrollWidth <= element.clientWidth + 1) return; |
| ... | ... | @@ -356,24 +368,26 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 356 | 368 | const element = turnStripRef.current; |
| 357 | 369 | if (!element) return; |
| 358 | 370 | |
| 359 | updateTurnStripScrollState(); | |
| 371 | const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState()); | |
| 360 | 372 | const onScroll = () => updateTurnStripScrollState(); |
| 361 | 373 | |
| 362 | 374 | element.addEventListener("scroll", onScroll, { passive: true }); |
| 363 | element.addEventListener("wheel", onTurnStripWheel, { passive: false }); | |
| 364 | 375 | |
| 365 | 376 | const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState()); |
| 366 | 377 | resizeObserver.observe(element); |
| 367 | 378 | |
| 368 | 379 | return () => { |
| 380 | window.cancelAnimationFrame(frameId); | |
| 369 | 381 | element.removeEventListener("scroll", onScroll); |
| 370 | element.removeEventListener("wheel", onTurnStripWheel); | |
| 371 | 382 | resizeObserver.disconnect(); |
| 372 | 383 | }; |
| 373 | }, [updateTurnStripScrollState, onTurnStripWheel]); | |
| 384 | }, [updateTurnStripScrollState]); | |
| 374 | 385 | |
| 375 | 386 | useEffect(() => { |
| 376 | updateTurnStripScrollState(); | |
| 387 | const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState()); | |
| 388 | return () => { | |
| 389 | window.cancelAnimationFrame(frameId); | |
| 390 | }; | |
| 377 | 391 | }, [orderedTurnDiffSummaries, selectedTurnId, updateTurnStripScrollState]); |
| 378 | 392 | |
| 379 | 393 | useEffect(() => { |
| ... | ... | @@ -422,7 +436,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 422 | 436 | > |
| 423 | 437 | <ChevronRightIcon className="size-3.5" /> |
| 424 | 438 | </button> |
| 425 | <div ref={turnStripRef} className="turn-chip-strip flex gap-1 overflow-x-auto px-8 py-0.5"> | |
| 439 | <div | |
| 440 | ref={turnStripRef} | |
| 441 | className="turn-chip-strip flex gap-1 overflow-x-auto px-8 py-0.5" | |
| 442 | onWheel={onTurnStripWheel} | |
| 443 | > | |
| 426 | 444 | <button |
| 427 | 445 | type="button" |
| 428 | 446 | className="shrink-0 rounded-md" |
apps/web/src/components/Sidebar.tsx+66-58| ... | ... | @@ -228,7 +228,10 @@ function ProjectFavicon({ cwd }: { cwd: string }) { |
| 228 | 228 | } |
| 229 | 229 | |
| 230 | 230 | export default function Sidebar() { |
| 231 | const { state, dispatch } = useStore(); | |
| 231 | const projects = useStore((state) => state.projects); | |
| 232 | const threads = useStore((state) => state.threads); | |
| 233 | const markThreadUnread = useStore((state) => state.markThreadUnread); | |
| 234 | const toggleProject = useStore((state) => state.toggleProject); | |
| 232 | 235 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); |
| 233 | 236 | const getDraftThreadByProjectId = useComposerDraftStore( |
| 234 | 237 | (store) => store.getDraftThreadByProjectId, |
| ... | ... | @@ -260,23 +263,23 @@ export default function Sidebar() { |
| 260 | 263 | const [isAddingProject, setIsAddingProject] = useState(false); |
| 261 | 264 | const pendingApprovalByThreadId = useMemo(() => { |
| 262 | 265 | const map = new Map<ThreadId, boolean>(); |
| 263 | for (const thread of state.threads) { | |
| 266 | for (const thread of threads) { | |
| 264 | 267 | map.set(thread.id, derivePendingApprovals(thread.activities).length > 0); |
| 265 | 268 | } |
| 266 | 269 | return map; |
| 267 | }, [state.threads]); | |
| 270 | }, [threads]); | |
| 268 | 271 | const projectCwdById = useMemo( |
| 269 | () => new Map(state.projects.map((project) => [project.id, project.cwd] as const)), | |
| 270 | [state.projects], | |
| 272 | () => new Map(projects.map((project) => [project.id, project.cwd] as const)), | |
| 273 | [projects], | |
| 271 | 274 | ); |
| 272 | 275 | const threadGitTargets = useMemo( |
| 273 | 276 | () => |
| 274 | state.threads.map((thread) => ({ | |
| 277 | threads.map((thread) => ({ | |
| 275 | 278 | threadId: thread.id, |
| 276 | 279 | branch: thread.branch, |
| 277 | 280 | cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null, |
| 278 | 281 | })), |
| 279 | [projectCwdById, state.threads], | |
| 282 | [projectCwdById, threads], | |
| 280 | 283 | ); |
| 281 | 284 | const threadGitStatusCwds = useMemo( |
| 282 | 285 | () => [ |
| ... | ... | @@ -414,7 +417,7 @@ export default function Sidebar() { |
| 414 | 417 | |
| 415 | 418 | const focusMostRecentThreadForProject = useCallback( |
| 416 | 419 | (projectId: ProjectId) => { |
| 417 | const latestThread = state.threads | |
| 420 | const latestThread = threads | |
| 418 | 421 | .filter((thread) => thread.projectId === projectId) |
| 419 | 422 | .toSorted((a, b) => { |
| 420 | 423 | const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); |
| ... | ... | @@ -428,7 +431,7 @@ export default function Sidebar() { |
| 428 | 431 | params: { threadId: latestThread.id }, |
| 429 | 432 | }); |
| 430 | 433 | }, |
| 431 | [navigate, state.threads], | |
| 434 | [navigate, threads], | |
| 432 | 435 | ); |
| 433 | 436 | |
| 434 | 437 | const addProjectFromPath = useCallback( |
| ... | ... | @@ -438,34 +441,36 @@ export default function Sidebar() { |
| 438 | 441 | const api = readNativeApi(); |
| 439 | 442 | |
| 440 | 443 | setIsAddingProject(true); |
| 441 | try { | |
| 442 | if (!api) return; | |
| 443 | const existing = state.projects.find((project) => project.cwd === cwd); | |
| 444 | if (api) { | |
| 445 | const existing = projects.find((project) => project.cwd === cwd); | |
| 444 | 446 | if (existing) { |
| 445 | 447 | focusMostRecentThreadForProject(existing.id); |
| 446 | return; | |
| 448 | } else { | |
| 449 | const projectId = newProjectId(); | |
| 450 | const createdAt = new Date().toISOString(); | |
| 451 | const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; | |
| 452 | const projectCreated = await api.orchestration | |
| 453 | .dispatchCommand({ | |
| 454 | type: "project.create", | |
| 455 | commandId: newCommandId(), | |
| 456 | projectId, | |
| 457 | title, | |
| 458 | workspaceRoot: cwd, | |
| 459 | defaultModel: DEFAULT_MODEL, | |
| 460 | createdAt, | |
| 461 | }) | |
| 462 | .then(() => true) | |
| 463 | .catch(() => false); | |
| 464 | if (projectCreated) { | |
| 465 | await handleNewThread(projectId).catch(() => undefined); | |
| 466 | } | |
| 447 | 467 | } |
| 448 | ||
| 449 | const projectId = newProjectId(); | |
| 450 | const createdAt = new Date().toISOString(); | |
| 451 | const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; | |
| 452 | await api.orchestration.dispatchCommand({ | |
| 453 | type: "project.create", | |
| 454 | commandId: newCommandId(), | |
| 455 | projectId, | |
| 456 | title, | |
| 457 | workspaceRoot: cwd, | |
| 458 | defaultModel: DEFAULT_MODEL, | |
| 459 | createdAt, | |
| 460 | }); | |
| 461 | await handleNewThread(projectId); | |
| 462 | } finally { | |
| 463 | setIsAddingProject(false); | |
| 464 | setNewCwd(""); | |
| 465 | setAddingProject(false); | |
| 466 | 468 | } |
| 469 | setIsAddingProject(false); | |
| 470 | setNewCwd(""); | |
| 471 | setAddingProject(false); | |
| 467 | 472 | }, |
| 468 | [focusMostRecentThreadForProject, handleNewThread, isAddingProject, state.projects], | |
| 473 | [focusMostRecentThreadForProject, handleNewThread, isAddingProject, projects], | |
| 469 | 474 | ); |
| 470 | 475 | |
| 471 | 476 | const handleAddProject = () => { |
| ... | ... | @@ -476,13 +481,16 @@ export default function Sidebar() { |
| 476 | 481 | const api = readNativeApi(); |
| 477 | 482 | if (!api || isPickingFolder) return; |
| 478 | 483 | setIsPickingFolder(true); |
| 484 | let pickedPath: string | null = null; | |
| 479 | 485 | try { |
| 480 | const pickedPath = await api.dialogs.pickFolder(); | |
| 481 | if (!pickedPath) return; | |
| 486 | pickedPath = await api.dialogs.pickFolder(); | |
| 487 | } catch { | |
| 488 | // Ignore picker failures and leave the current thread selection unchanged. | |
| 489 | } | |
| 490 | if (pickedPath) { | |
| 482 | 491 | await addProjectFromPath(pickedPath); |
| 483 | } finally { | |
| 484 | setIsPickingFolder(false); | |
| 485 | 492 | } |
| 493 | setIsPickingFolder(false); | |
| 486 | 494 | }; |
| 487 | 495 | |
| 488 | 496 | const handleThreadContextMenu = useCallback( |
| ... | ... | @@ -497,12 +505,12 @@ export default function Sidebar() { |
| 497 | 505 | position, |
| 498 | 506 | ); |
| 499 | 507 | if (clicked === "mark-unread") { |
| 500 | dispatch({ type: "MARK_THREAD_UNREAD", threadId }); | |
| 508 | markThreadUnread(threadId); | |
| 501 | 509 | return; |
| 502 | 510 | } |
| 503 | 511 | if (clicked !== "delete") return; |
| 504 | 512 | |
| 505 | const thread = state.threads.find((t) => t.id === threadId); | |
| 513 | const thread = threads.find((t) => t.id === threadId); | |
| 506 | 514 | if (!thread) return; |
| 507 | 515 | if (appSettings.confirmThreadDelete) { |
| 508 | 516 | const confirmed = await api.dialogs.confirm( |
| ... | ... | @@ -515,8 +523,8 @@ export default function Sidebar() { |
| 515 | 523 | return; |
| 516 | 524 | } |
| 517 | 525 | } |
| 518 | const threadProject = state.projects.find((project) => project.id === thread.projectId); | |
| 519 | const orphanedWorktreePath = getOrphanedWorktreePathForThread(state.threads, threadId); | |
| 526 | const threadProject = projects.find((project) => project.id === thread.projectId); | |
| 527 | const orphanedWorktreePath = getOrphanedWorktreePathForThread(threads, threadId); | |
| 520 | 528 | const displayWorktreePath = orphanedWorktreePath |
| 521 | 529 | ? formatWorktreePathForDisplay(orphanedWorktreePath) |
| 522 | 530 | : null; |
| ... | ... | @@ -553,7 +561,7 @@ export default function Sidebar() { |
| 553 | 561 | } |
| 554 | 562 | |
| 555 | 563 | const shouldNavigateToFallback = routeThreadId === threadId; |
| 556 | const fallbackThreadId = state.threads.find((entry) => entry.id !== threadId)?.id ?? null; | |
| 564 | const fallbackThreadId = threads.find((entry) => entry.id !== threadId)?.id ?? null; | |
| 557 | 565 | await api.orchestration.dispatchCommand({ |
| 558 | 566 | type: "thread.delete", |
| 559 | 567 | commandId: newCommandId(), |
| ... | ... | @@ -602,12 +610,12 @@ export default function Sidebar() { |
| 602 | 610 | appSettings.confirmThreadDelete, |
| 603 | 611 | clearComposerDraftForThread, |
| 604 | 612 | clearProjectDraftThreadById, |
| 605 | dispatch, | |
| 613 | markThreadUnread, | |
| 606 | 614 | navigate, |
| 607 | 615 | removeWorktreeMutation, |
| 608 | 616 | routeThreadId, |
| 609 | state.projects, | |
| 610 | state.threads, | |
| 617 | projects, | |
| 618 | threads, | |
| 611 | 619 | ], |
| 612 | 620 | ); |
| 613 | 621 | |
| ... | ... | @@ -618,10 +626,10 @@ export default function Sidebar() { |
| 618 | 626 | const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position); |
| 619 | 627 | if (clicked !== "delete") return; |
| 620 | 628 | |
| 621 | const project = state.projects.find((entry) => entry.id === projectId); | |
| 629 | const project = projects.find((entry) => entry.id === projectId); | |
| 622 | 630 | if (!project) return; |
| 623 | 631 | |
| 624 | const projectThreads = state.threads.filter((thread) => thread.projectId === projectId); | |
| 632 | const projectThreads = threads.filter((thread) => thread.projectId === projectId); | |
| 625 | 633 | if (projectThreads.length > 0) { |
| 626 | 634 | toastManager.add({ |
| 627 | 635 | type: "warning", |
| ... | ... | @@ -661,20 +669,20 @@ export default function Sidebar() { |
| 661 | 669 | clearComposerDraftForThread, |
| 662 | 670 | clearProjectDraftThreadId, |
| 663 | 671 | getDraftThreadByProjectId, |
| 664 | state.projects, | |
| 665 | state.threads, | |
| 672 | projects, | |
| 673 | threads, | |
| 666 | 674 | ], |
| 667 | 675 | ); |
| 668 | 676 | |
| 669 | 677 | useEffect(() => { |
| 670 | 678 | const onWindowKeyDown = (event: KeyboardEvent) => { |
| 671 | 679 | const activeThread = routeThreadId |
| 672 | ? state.threads.find((thread) => thread.id === routeThreadId) | |
| 680 | ? threads.find((thread) => thread.id === routeThreadId) | |
| 673 | 681 | : undefined; |
| 674 | 682 | const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; |
| 675 | 683 | if (isChatNewLocalShortcut(event, keybindings)) { |
| 676 | 684 | const projectId = |
| 677 | activeThread?.projectId ?? activeDraftThread?.projectId ?? state.projects[0]?.id; | |
| 685 | activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; | |
| 678 | 686 | if (!projectId) return; |
| 679 | 687 | event.preventDefault(); |
| 680 | 688 | void handleNewThread(projectId); |
| ... | ... | @@ -683,7 +691,7 @@ export default function Sidebar() { |
| 683 | 691 | |
| 684 | 692 | if (!isChatNewShortcut(event, keybindings)) return; |
| 685 | 693 | const projectId = |
| 686 | activeThread?.projectId ?? activeDraftThread?.projectId ?? state.projects[0]?.id; | |
| 694 | activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; | |
| 687 | 695 | if (!projectId) return; |
| 688 | 696 | event.preventDefault(); |
| 689 | 697 | void handleNewThread(projectId, { |
| ... | ... | @@ -697,14 +705,14 @@ export default function Sidebar() { |
| 697 | 705 | return () => { |
| 698 | 706 | window.removeEventListener("keydown", onWindowKeyDown); |
| 699 | 707 | }; |
| 700 | }, [getDraftThread, handleNewThread, keybindings, routeThreadId, state.projects, state.threads]); | |
| 708 | }, [getDraftThread, handleNewThread, keybindings, routeThreadId, projects, threads]); | |
| 701 | 709 | |
| 702 | 710 | const onCreateThreadClick = () => { |
| 703 | if (state.projects.length === 0) { | |
| 711 | if (projects.length === 0) { | |
| 704 | 712 | setAddingProject(true); |
| 705 | 713 | return; |
| 706 | 714 | } |
| 707 | const firstProject = state.projects[0]; | |
| 715 | const firstProject = projects[0]; | |
| 708 | 716 | if (firstProject) void handleNewThread(firstProject.id); |
| 709 | 717 | }; |
| 710 | 718 | |
| ... | ... | @@ -753,8 +761,8 @@ export default function Sidebar() { |
| 753 | 761 | <SidebarContent className="gap-0"> |
| 754 | 762 | <SidebarGroup className="px-2 py-2"> |
| 755 | 763 | <SidebarMenu> |
| 756 | {state.projects.map((project) => { | |
| 757 | const visibleThreads = state.threads | |
| 764 | {projects.map((project) => { | |
| 765 | const visibleThreads = threads | |
| 758 | 766 | .filter((thread) => thread.projectId === project.id) |
| 759 | 767 | .toSorted((a, b) => { |
| 760 | 768 | const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); |
| ... | ... | @@ -769,7 +777,7 @@ export default function Sidebar() { |
| 769 | 777 | open={project.expanded} |
| 770 | 778 | onOpenChange={(open) => { |
| 771 | 779 | if (open === project.expanded) return; |
| 772 | dispatch({ type: "TOGGLE_PROJECT", projectId: project.id }); | |
| 780 | toggleProject(project.id); | |
| 773 | 781 | }} |
| 774 | 782 | > |
| 775 | 783 | <SidebarMenuItem> |
| ... | ... | @@ -929,7 +937,7 @@ export default function Sidebar() { |
| 929 | 937 | })} |
| 930 | 938 | </SidebarMenu> |
| 931 | 939 | |
| 932 | {state.projects.length === 0 && !addingProject && ( | |
| 940 | {projects.length === 0 && !addingProject && ( | |
| 933 | 941 | <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60"> |
| 934 | 942 | No projects yet. |
| 935 | 943 | <br /> |
apps/web/src/routes/__root.tsx+16-17| ... | ... | @@ -123,14 +123,19 @@ function errorDetails(error: unknown): string { |
| 123 | 123 | } |
| 124 | 124 | |
| 125 | 125 | function EventRouter() { |
| 126 | const { dispatch } = useStore(); | |
| 126 | const syncServerReadModel = useStore((state) => state.syncServerReadModel); | |
| 127 | const setThreadTerminalActivity = useStore((state) => state.setThreadTerminalActivity); | |
| 128 | const setProjectExpanded = useStore((state) => state.setProjectExpanded); | |
| 127 | 129 | const queryClient = useQueryClient(); |
| 128 | 130 | const navigate = useNavigate(); |
| 129 | 131 | const pathname = useRouterState({ select: (state) => state.location.pathname }); |
| 130 | 132 | const pathnameRef = useRef(pathname); |
| 131 | 133 | const lastConfigIssuesSignatureRef = useRef<string | null>(null); |
| 132 | 134 | const handledBootstrapThreadIdRef = useRef<string | null>(null); |
| 133 | pathnameRef.current = pathname; | |
| 135 | ||
| 136 | useEffect(() => { | |
| 137 | pathnameRef.current = pathname; | |
| 138 | }, [pathname]); | |
| 134 | 139 | |
| 135 | 140 | useEffect(() => { |
| 136 | 141 | const api = readNativeApi(); |
| ... | ... | @@ -144,7 +149,7 @@ function EventRouter() { |
| 144 | 149 | const snapshot = await api.orchestration.getSnapshot(); |
| 145 | 150 | if (disposed) return; |
| 146 | 151 | latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); |
| 147 | dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot }); | |
| 152 | syncServerReadModel(snapshot); | |
| 148 | 153 | if (pending) { |
| 149 | 154 | pending = false; |
| 150 | 155 | await flushSnapshotSync(); |
| ... | ... | @@ -157,14 +162,13 @@ function EventRouter() { |
| 157 | 162 | return; |
| 158 | 163 | } |
| 159 | 164 | syncing = true; |
| 165 | pending = false; | |
| 160 | 166 | try { |
| 161 | pending = false; | |
| 162 | 167 | await flushSnapshotSync(); |
| 163 | 168 | } catch { |
| 164 | 169 | // Keep prior state and wait for next domain event to trigger a resync. |
| 165 | } finally { | |
| 166 | syncing = false; | |
| 167 | 170 | } |
| 171 | syncing = false; | |
| 168 | 172 | }; |
| 169 | 173 | |
| 170 | 174 | void syncSnapshot().catch(() => undefined); |
| ... | ... | @@ -184,12 +188,11 @@ function EventRouter() { |
| 184 | 188 | if (hasRunningSubprocess === null) { |
| 185 | 189 | return; |
| 186 | 190 | } |
| 187 | dispatch({ | |
| 188 | type: "SET_THREAD_TERMINAL_ACTIVITY", | |
| 189 | threadId: ThreadId.makeUnsafe(event.threadId), | |
| 190 | terminalId: event.terminalId, | |
| 191 | setThreadTerminalActivity( | |
| 192 | ThreadId.makeUnsafe(event.threadId), | |
| 193 | event.terminalId, | |
| 191 | 194 | hasRunningSubprocess, |
| 192 | }); | |
| 195 | ); | |
| 193 | 196 | }); |
| 194 | 197 | const unsubWelcome = onServerWelcome((payload) => { |
| 195 | 198 | void (async () => { |
| ... | ... | @@ -201,11 +204,7 @@ function EventRouter() { |
| 201 | 204 | if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { |
| 202 | 205 | return; |
| 203 | 206 | } |
| 204 | dispatch({ | |
| 205 | type: "SET_PROJECT_EXPANDED", | |
| 206 | projectId: payload.bootstrapProjectId, | |
| 207 | expanded: true, | |
| 208 | }); | |
| 207 | setProjectExpanded(payload.bootstrapProjectId, true); | |
| 209 | 208 | |
| 210 | 209 | if (pathnameRef.current !== "/") { |
| 211 | 210 | return; |
| ... | ... | @@ -270,7 +269,7 @@ function EventRouter() { |
| 270 | 269 | unsubWelcome(); |
| 271 | 270 | unsubServerConfigUpdated(); |
| 272 | 271 | }; |
| 273 | }, [dispatch, navigate, queryClient]); | |
| 272 | }, [navigate, queryClient, setProjectExpanded, setThreadTerminalActivity, syncServerReadModel]); | |
| 274 | 273 | |
| 275 | 274 | return null; |
| 276 | 275 | } |
apps/web/src/routes/_chat.$threadId.tsx+7-5| ... | ... | @@ -133,13 +133,15 @@ const DiffPanelInlineSidebar = (props: { |
| 133 | 133 | }; |
| 134 | 134 | |
| 135 | 135 | function ChatThreadRouteView() { |
| 136 | const { state } = useStore(); | |
| 136 | const threadsHydrated = useStore((state) => state.threadsHydrated); | |
| 137 | 137 | const navigate = useNavigate(); |
| 138 | 138 | const threadId = Route.useParams({ |
| 139 | 139 | select: (params) => ThreadId.makeUnsafe(params.threadId), |
| 140 | 140 | }); |
| 141 | 141 | const search = Route.useSearch(); |
| 142 | const threadExists = state.threads.some((thread) => thread.id === threadId); | |
| 142 | const threadExists = useStore( | |
| 143 | useCallback((state) => state.threads.some((thread) => thread.id === threadId), [threadId]), | |
| 144 | ); | |
| 143 | 145 | const draftThreadExists = useComposerDraftStore( |
| 144 | 146 | (store) => Object.hasOwn(store.draftThreadsByThreadId, threadId), |
| 145 | 147 | ); |
| ... | ... | @@ -167,7 +169,7 @@ function ChatThreadRouteView() { |
| 167 | 169 | }, [navigate, threadId]); |
| 168 | 170 | |
| 169 | 171 | useEffect(() => { |
| 170 | if (!state.threadsHydrated) { | |
| 172 | if (!threadsHydrated) { | |
| 171 | 173 | return; |
| 172 | 174 | } |
| 173 | 175 | |
| ... | ... | @@ -175,9 +177,9 @@ function ChatThreadRouteView() { |
| 175 | 177 | void navigate({ to: "/", replace: true }); |
| 176 | 178 | return; |
| 177 | 179 | } |
| 178 | }, [navigate, routeThreadExists, state.threadsHydrated, threadId]); | |
| 180 | }, [navigate, routeThreadExists, threadsHydrated, threadId]); | |
| 179 | 181 | |
| 180 | if (!state.threadsHydrated || !routeThreadExists) { | |
| 182 | if (!threadsHydrated || !routeThreadExists) { | |
| 181 | 183 | return null; |
| 182 | 184 | } |
| 183 | 185 |
apps/web/src/routes/_chat.settings.tsx+18-18| ... | ... | @@ -48,7 +48,8 @@ const RUNTIME_MODE_OPTIONS = [ |
| 48 | 48 | |
| 49 | 49 | function SettingsRouteView() { |
| 50 | 50 | const { theme, setTheme, resolvedTheme } = useTheme(); |
| 51 | const { state, dispatch } = useStore(); | |
| 51 | const runtimeMode = useStore((state) => state.runtimeMode); | |
| 52 | const setRuntimeMode = useStore((state) => state.setRuntimeMode); | |
| 52 | 53 | const { settings, defaults, updateSettings } = useAppSettings(); |
| 53 | 54 | const serverConfigQuery = useQuery(serverConfigQueryOptions()); |
| 54 | 55 | const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); |
| ... | ... | @@ -186,9 +187,10 @@ function SettingsRouteView() { |
| 186 | 187 | </div> |
| 187 | 188 | |
| 188 | 189 | <div className="space-y-4"> |
| 189 | <label className="block space-y-1"> | |
| 190 | <label htmlFor="codex-binary-path" className="block space-y-1"> | |
| 190 | 191 | <span className="text-xs font-medium text-foreground">Codex binary path</span> |
| 191 | 192 | <Input |
| 193 | id="codex-binary-path" | |
| 192 | 194 | value={codexBinaryPath} |
| 193 | 195 | onChange={(event) => updateSettings({ codexBinaryPath: event.target.value })} |
| 194 | 196 | placeholder="codex" |
| ... | ... | @@ -199,9 +201,10 @@ function SettingsRouteView() { |
| 199 | 201 | </span> |
| 200 | 202 | </label> |
| 201 | 203 | |
| 202 | <label className="block space-y-1"> | |
| 204 | <label htmlFor="codex-home-path" className="block space-y-1"> | |
| 203 | 205 | <span className="text-xs font-medium text-foreground">CODEX_HOME path</span> |
| 204 | 206 | <Input |
| 207 | id="codex-home-path" | |
| 205 | 208 | value={codexHomePath} |
| 206 | 209 | onChange={(event) => updateSettings({ codexHomePath: event.target.value })} |
| 207 | 210 | placeholder="/Users/you/.codex" |
| ... | ... | @@ -242,16 +245,11 @@ function SettingsRouteView() { |
| 242 | 245 | </div> |
| 243 | 246 | |
| 244 | 247 | <div className="space-y-4"> |
| 245 | <form | |
| 246 | className="flex flex-col gap-3 sm:flex-row sm:items-start" | |
| 247 | onSubmit={(event) => { | |
| 248 | event.preventDefault(); | |
| 249 | addCustomModel(); | |
| 250 | }} | |
| 251 | > | |
| 252 | <label className="block flex-1 space-y-1"> | |
| 248 | <div className="flex flex-col gap-3 sm:flex-row sm:items-start"> | |
| 249 | <label htmlFor="custom-model-slug" className="block flex-1 space-y-1"> | |
| 253 | 250 | <span className="text-xs font-medium text-foreground">Custom model slug</span> |
| 254 | 251 | <Input |
| 252 | id="custom-model-slug" | |
| 255 | 253 | value={customModelInput} |
| 256 | 254 | onChange={(event) => { |
| 257 | 255 | setCustomModelInput(event.target.value); |
| ... | ... | @@ -259,6 +257,11 @@ function SettingsRouteView() { |
| 259 | 257 | setCustomModelError(null); |
| 260 | 258 | } |
| 261 | 259 | }} |
| 260 | onKeyDown={(event) => { | |
| 261 | if (event.key !== "Enter") return; | |
| 262 | event.preventDefault(); | |
| 263 | addCustomModel(); | |
| 264 | }} | |
| 262 | 265 | placeholder="your-model-slug" |
| 263 | 266 | spellCheck={false} |
| 264 | 267 | /> |
| ... | ... | @@ -267,10 +270,10 @@ function SettingsRouteView() { |
| 267 | 270 | </span> |
| 268 | 271 | </label> |
| 269 | 272 | |
| 270 | <Button className="sm:mt-6" type="submit"> | |
| 273 | <Button className="sm:mt-6" type="button" onClick={addCustomModel}> | |
| 271 | 274 | Add model |
| 272 | 275 | </Button> |
| 273 | </form> | |
| 276 | </div> | |
| 274 | 277 | |
| 275 | 278 | {customModelError ? ( |
| 276 | 279 | <p className="text-xs text-destructive">{customModelError}</p> |
| ... | ... | @@ -333,7 +336,7 @@ function SettingsRouteView() { |
| 333 | 336 | |
| 334 | 337 | <div className="space-y-2" role="radiogroup" aria-label="Runtime mode preference"> |
| 335 | 338 | {RUNTIME_MODE_OPTIONS.map((option) => { |
| 336 | const selected = state.runtimeMode === option.value; | |
| 339 | const selected = runtimeMode === option.value; | |
| 337 | 340 | return ( |
| 338 | 341 | <button |
| 339 | 342 | key={option.value} |
| ... | ... | @@ -346,10 +349,7 @@ function SettingsRouteView() { |
| 346 | 349 | : "border-border bg-background text-muted-foreground hover:bg-accent" |
| 347 | 350 | }`} |
| 348 | 351 | onClick={() => { |
| 349 | dispatch({ | |
| 350 | type: "SET_RUNTIME_MODE", | |
| 351 | mode: option.value, | |
| 352 | }); | |
| 352 | setRuntimeMode(option.value); | |
| 353 | 353 | }} |
| 354 | 354 | > |
| 355 | 355 | <span className="flex flex-col"> |
apps/web/src/store.ts+84-25| ... | ... | @@ -1,12 +1,5 @@ |
| 1 | import { | |
| 2 | type Dispatch, | |
| 3 | type ReactNode, | |
| 4 | createContext, | |
| 5 | createElement, | |
| 6 | useContext, | |
| 7 | useEffect, | |
| 8 | useReducer, | |
| 9 | } from "react"; | |
| 1 | import { type ReactNode } from "react"; | |
| 2 | import { create } from "zustand"; | |
| 10 | 3 | |
| 11 | 4 | import { |
| 12 | 5 | DEFAULT_MODEL, |
| ... | ... | @@ -67,6 +60,29 @@ export interface AppState { |
| 67 | 60 | runtimeMode: RuntimeMode; |
| 68 | 61 | } |
| 69 | 62 | |
| 63 | interface AppStore extends AppState { | |
| 64 | syncServerReadModel: (readModel: OrchestrationReadModel) => void; | |
| 65 | markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void; | |
| 66 | markThreadUnread: (threadId: ThreadId) => void; | |
| 67 | toggleProject: (projectId: Project["id"]) => void; | |
| 68 | setThreadTerminalActivity: ( | |
| 69 | threadId: ThreadId, | |
| 70 | terminalId: string, | |
| 71 | hasRunningSubprocess: boolean, | |
| 72 | ) => void; | |
| 73 | setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void; | |
| 74 | toggleThreadTerminal: (threadId: ThreadId) => void; | |
| 75 | setThreadTerminalOpen: (threadId: ThreadId, open: boolean) => void; | |
| 76 | setThreadTerminalHeight: (threadId: ThreadId, height: number) => void; | |
| 77 | splitThreadTerminal: (threadId: ThreadId, terminalId: string) => void; | |
| 78 | newThreadTerminal: (threadId: ThreadId, terminalId: string) => void; | |
| 79 | setThreadActiveTerminal: (threadId: ThreadId, terminalId: string) => void; | |
| 80 | closeThreadTerminal: (threadId: ThreadId, terminalId: string) => void; | |
| 81 | setThreadError: (threadId: ThreadId, error: string | null) => void; | |
| 82 | setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void; | |
| 83 | setRuntimeMode: (mode: RuntimeMode) => void; | |
| 84 | } | |
| 85 | ||
| 70 | 86 | const PERSISTED_STATE_KEY = "t3code:renderer-state:v7"; |
| 71 | 87 | const LEGACY_PERSISTED_STATE_KEYS = [ |
| 72 | 88 | "t3code:renderer-state:v6", |
| ... | ... | @@ -804,23 +820,66 @@ export function reducer(state: AppState, action: Action): AppState { |
| 804 | 820 | } |
| 805 | 821 | } |
| 806 | 822 | |
| 807 | // ── Context ────────────────────────────────────────────────────────── | |
| 808 | ||
| 809 | const StoreContext = createContext<{ | |
| 810 | state: AppState; | |
| 811 | dispatch: Dispatch<Action>; | |
| 812 | }>({ state: initialState, dispatch: () => {} }); | |
| 813 | ||
| 814 | export function StoreProvider({ children }: { children: ReactNode }) { | |
| 815 | const [state, dispatch] = useReducer(reducer, undefined, readPersistedState); | |
| 823 | function toAppState(store: AppStore): AppState { | |
| 824 | return { | |
| 825 | projects: store.projects, | |
| 826 | threads: store.threads, | |
| 827 | threadsHydrated: store.threadsHydrated, | |
| 828 | runtimeMode: store.runtimeMode, | |
| 829 | }; | |
| 830 | } | |
| 816 | 831 | |
| 817 | useEffect(() => { | |
| 818 | persistState(state); | |
| 819 | }, [state]); | |
| 832 | export const useStore = create<AppStore>((set, get) => { | |
| 833 | const applyAction = (action: Action): void => { | |
| 834 | const currentState = toAppState(get()); | |
| 835 | const nextState = reducer(currentState, action); | |
| 836 | if (nextState === currentState) { | |
| 837 | return; | |
| 838 | } | |
| 839 | persistState(nextState); | |
| 840 | set(nextState); | |
| 841 | }; | |
| 820 | 842 | |
| 821 | return createElement(StoreContext.Provider, { value: { state, dispatch } }, children); | |
| 822 | } | |
| 843 | return { | |
| 844 | ...readPersistedState(), | |
| 845 | syncServerReadModel: (readModel) => applyAction({ type: "SYNC_SERVER_READ_MODEL", readModel }), | |
| 846 | markThreadVisited: (threadId, visitedAt) => | |
| 847 | applyAction({ | |
| 848 | type: "MARK_THREAD_VISITED", | |
| 849 | threadId, | |
| 850 | ...(visitedAt === undefined ? {} : { visitedAt }), | |
| 851 | }), | |
| 852 | markThreadUnread: (threadId) => applyAction({ type: "MARK_THREAD_UNREAD", threadId }), | |
| 853 | toggleProject: (projectId) => applyAction({ type: "TOGGLE_PROJECT", projectId }), | |
| 854 | setThreadTerminalActivity: (threadId, terminalId, hasRunningSubprocess) => | |
| 855 | applyAction({ | |
| 856 | type: "SET_THREAD_TERMINAL_ACTIVITY", | |
| 857 | threadId, | |
| 858 | terminalId, | |
| 859 | hasRunningSubprocess, | |
| 860 | }), | |
| 861 | setProjectExpanded: (projectId, expanded) => | |
| 862 | applyAction({ type: "SET_PROJECT_EXPANDED", projectId, expanded }), | |
| 863 | toggleThreadTerminal: (threadId) => applyAction({ type: "TOGGLE_THREAD_TERMINAL", threadId }), | |
| 864 | setThreadTerminalOpen: (threadId, open) => | |
| 865 | applyAction({ type: "SET_THREAD_TERMINAL_OPEN", threadId, open }), | |
| 866 | setThreadTerminalHeight: (threadId, height) => | |
| 867 | applyAction({ type: "SET_THREAD_TERMINAL_HEIGHT", threadId, height }), | |
| 868 | splitThreadTerminal: (threadId, terminalId) => | |
| 869 | applyAction({ type: "SPLIT_THREAD_TERMINAL", threadId, terminalId }), | |
| 870 | newThreadTerminal: (threadId, terminalId) => | |
| 871 | applyAction({ type: "NEW_THREAD_TERMINAL", threadId, terminalId }), | |
| 872 | setThreadActiveTerminal: (threadId, terminalId) => | |
| 873 | applyAction({ type: "SET_THREAD_ACTIVE_TERMINAL", threadId, terminalId }), | |
| 874 | closeThreadTerminal: (threadId, terminalId) => | |
| 875 | applyAction({ type: "CLOSE_THREAD_TERMINAL", threadId, terminalId }), | |
| 876 | setThreadError: (threadId, error) => applyAction({ type: "SET_ERROR", threadId, error }), | |
| 877 | setThreadBranch: (threadId, branch, worktreePath) => | |
| 878 | applyAction({ type: "SET_THREAD_BRANCH", threadId, branch, worktreePath }), | |
| 879 | setRuntimeMode: (mode) => applyAction({ type: "SET_RUNTIME_MODE", mode }), | |
| 880 | }; | |
| 881 | }); | |
| 823 | 882 | |
| 824 | export function useStore() { | |
| 825 | return useContext(StoreContext); | |
| 883 | export function StoreProvider({ children }: { children: ReactNode }) { | |
| 884 | return children; | |
| 826 | 885 | } |