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