authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 00:24:07-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 13:13:19-08:00
logbe4d26a1b31d96754bdadc2ab7094dfdc9c7bad9
treea7f86bfcb2c98319ebe02cf07ca51ff0c96111a7
parent5c0961431909f83536790677f46b6a08dadb3434

Refactor web store usage to granular selectors

- 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
3Run after making React changes to catch issues early. Use when reviewing code, finishing a feature, or fixing bugs in a React project.
4
5Scans your React codebase for security, performance, correctness, and architecture issues. Outputs a 0-100 score with actionable diagnostics.
6
7## Usage
8
9```bash
10npx -y react-doctor@latest . --verbose --diff
11```
12
13## Workflow
14
15Run 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---
2name: react-doctor
3description: Run after making React changes to catch issues early. Use when reviewing code, finishing a feature, or fixing bugs in a React project.
4version: 1.0.0
5---
6
7# React Doctor
8
9Scans your React codebase for security, performance, correctness, and architecture issues. Outputs a 0-100 score with actionable diagnostics.
10
11## Usage
12
13```bash
14npx -y react-doctor@latest . --verbose --diff
15```
16
17## Workflow
18
19Run 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 @@
11import type { GitBranch, ThreadId } from "@t3tools/contracts";
22import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useEffect, useState } from "react";
3import { useCallback, useEffect, useState } from "react";
44
55import { newCommandId } from "../lib/utils";
66import {
......@@ -43,17 +43,26 @@ export default function BranchToolbar({
4343 envLocked,
4444 onComposerFocusRequest,
4545}: BranchToolbarProps) {
46 const { state, dispatch } = useStore();
46 const setThreadBranchAction = useStore((state) => state.setThreadBranch);
47 const setThreadErrorAction = useStore((state) => state.setThreadError);
4748 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 );
4860 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
4961 const queryClient = useQueryClient();
5062
5163 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
5264 const [branchQuery, setBranchQuery] = useState("");
5365
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);
5766 const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined);
5867 const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null;
5968 const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
......@@ -114,12 +123,7 @@ export default function BranchToolbar({
114123 });
115124 }
116125 if (hasServerThread) {
117 dispatch({
118 type: "SET_THREAD_BRANCH",
119 threadId: activeThreadId,
120 branch: syncedBranch,
121 worktreePath: null,
122 });
126 setThreadBranchAction(activeThreadId, syncedBranch, null);
123127 return;
124128 }
125129 setDraftThreadContext(threadId, {
......@@ -132,22 +136,17 @@ export default function BranchToolbar({
132136 activeThreadBranch,
133137 queryBranches,
134138 effectiveEnvMode,
135 dispatch,
139 setThreadBranchAction,
136140 hasServerThread,
137141 setDraftThreadContext,
138142 threadId,
139143 ]);
140144
141 useEffect(() => {
142 if (isBranchMenuOpen) return;
143 setBranchQuery("");
144 }, [isBranchMenuOpen]);
145
146145 // ── Helpers ───────────────────────────────────────────────────────────
147146
148147 const setThreadError = (error: string | null) => {
149148 if (!activeThreadId) return;
150 dispatch({ type: "SET_ERROR", threadId: activeThreadId, error });
149 setThreadErrorAction(activeThreadId, error);
151150 };
152151
153152 const setThreadBranch = (branch: string | null, worktreePath: string | null) => {
......@@ -176,7 +175,7 @@ export default function BranchToolbar({
176175 });
177176 }
178177 if (hasServerThread) {
179 dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThreadId, branch, worktreePath });
178 setThreadBranchAction(activeThreadId, branch, worktreePath);
180179 return;
181180 }
182181 setDraftThreadContext(threadId, {
......@@ -282,7 +281,12 @@ export default function BranchToolbar({
282281 <Combobox
283282 items={branchPickerItems}
284283 autoHighlight
285 onOpenChange={(open) => setIsBranchMenuOpen(open)}
284 onOpenChange={(open) => {
285 setIsBranchMenuOpen(open);
286 if (!open) {
287 setBranchQuery("");
288 }
289 }}
286290 open={isBranchMenuOpen}
287291 value={activeThreadBranch}
288292 >
apps/web/src/components/ChatView.tsx+193-185
......@@ -101,7 +101,7 @@ import { CursorIcon, Icon } from "./Icons";
101101import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
102102import { Badge } from "./ui/badge";
103103import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
104import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
104import { Command, CommandItem, CommandList } from "./ui/command";
105105import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
106106import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";
107107import {
......@@ -337,15 +337,12 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {
337337 kind: "file" | "directory";
338338 theme: "light" | "dark";
339339}) {
340 const [failed, setFailed] = useState(false);
340 const [failedIconUrl, setFailedIconUrl] = useState<string | null>(null);
341341 const iconUrl = useMemo(
342342 () => getVscodeIconUrlForEntry(props.pathValue, props.kind, props.theme),
343343 [props.kind, props.pathValue, props.theme],
344344 );
345
346 useEffect(() => {
347 setFailed(false);
348 }, [iconUrl]);
345 const failed = failedIconUrl === iconUrl;
349346
350347 if (failed) {
351348 return props.kind === "directory" ? (
......@@ -362,7 +359,7 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {
362359 aria-hidden="true"
363360 className="size-4 shrink-0"
364361 loading="lazy"
365 onError={() => setFailed(true)}
362 onError={() => setFailedIconUrl(iconUrl)}
366363 />
367364 );
368365});
......@@ -411,7 +408,6 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
411408 triggerKind: ComposerTriggerKind | null;
412409 onHighlightedItemChange: (itemId: string | null) => void;
413410 onSelect: (item: ComposerCommandItem) => void;
414 commandInputRef: React.RefObject<HTMLInputElement | null>;
415411}) {
416412 return (
417413 <Command
......@@ -423,9 +419,6 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
423419 }}
424420 >
425421 <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>
429422 <CommandList className="max-h-64">
430423 {props.items.map((item) => (
431424 <ComposerCommandMenuItem
......@@ -455,7 +448,17 @@ interface ChatViewProps {
455448}
456449
457450export 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);
459462 const { settings } = useAppSettings();
460463 const navigate = useNavigate();
461464 const rawSearch = useSearch({
......@@ -487,6 +490,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
487490 const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread);
488491 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
489492 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 );
490506 const promptRef = useRef(prompt);
491507 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
492508 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
......@@ -511,6 +527,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
511527 Record<string, string>
512528 >(() => readLastInvokedScriptByProjectFromStorage());
513529 const messagesScrollRef = useRef<HTMLDivElement>(null);
530 const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null);
514531 const shouldAutoScrollRef = useRef(true);
515532 const pendingAutoScrollFrameRef = useRef<number | null>(null);
516533 const textareaRef = useRef<HTMLTextAreaElement>(null);
......@@ -523,6 +540,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
523540 const sendInFlightRef = useRef(false);
524541 const dragDepthRef = useRef(0);
525542 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
543 const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => {
544 messagesScrollRef.current = element;
545 setMessagesScrollElement(element);
546 }, []);
526547
527548 const setPrompt = useCallback(
528549 (nextPrompt: string) => {
......@@ -555,8 +576,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
555576 [removeComposerDraftImage, threadId],
556577 );
557578
558 const serverThread = state.threads.find((t) => t.id === threadId);
559 const fallbackDraftProject = state.projects.find((project) => project.id === draftThread?.projectId);
560579 const localDraftError = localDraftErrorsByThreadId[threadId] ?? null;
561580 const localDraftThread = useMemo(
562581 () =>
......@@ -581,7 +600,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
581600 const activeThreadId = activeThread?.id ?? null;
582601 const activeLatestTurn = activeThread?.latestTurn ?? null;
583602 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 );
585611
586612 useEffect(() => {
587613 if (!serverThread) {
......@@ -605,16 +631,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
605631 const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN;
606632 if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return;
607633
608 dispatch({
609 type: "MARK_THREAD_VISITED",
610 threadId: activeThread.id,
611 });
634 markThreadVisited(activeThread.id);
612635 }, [
613636 activeThread?.id,
614637 activeThread?.lastVisitedAt,
615638 activeLatestTurn?.completedAt,
616639 latestTurnSettled,
617 dispatch,
640 markThreadVisited,
618641 ]);
619642
620643 const baseThreadModel = resolveModelSlug(activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL);
......@@ -981,41 +1004,25 @@ export default function ChatView({ threadId }: ChatViewProps) {
9811004 }, [focusComposer]);
9821005 const toggleTerminalVisibility = useCallback(() => {
9831006 if (!activeThreadId) return;
984 dispatch({
985 type: "SET_THREAD_TERMINAL_OPEN",
986 threadId: activeThreadId,
987 open: !activeThread?.terminalOpen,
988 });
989 }, [activeThread?.terminalOpen, activeThreadId, dispatch]);
1007 setThreadTerminalOpen(activeThreadId, !activeThread?.terminalOpen);
1008 }, [activeThread?.terminalOpen, activeThreadId, setThreadTerminalOpen]);
9901009 const splitTerminal = useCallback(() => {
9911010 if (!activeThreadId || hasReachedTerminalLimit) return;
992 dispatch({
993 type: "SPLIT_THREAD_TERMINAL",
994 threadId: activeThreadId,
995 terminalId: `terminal-${crypto.randomUUID()}`,
996 });
1011 splitThreadTerminalAction(activeThreadId, `terminal-${crypto.randomUUID()}`);
9971012 setTerminalFocusRequestId((value) => value + 1);
998 }, [activeThreadId, dispatch, hasReachedTerminalLimit]);
1013 }, [activeThreadId, hasReachedTerminalLimit, splitThreadTerminalAction]);
9991014 const createNewTerminal = useCallback(() => {
10001015 if (!activeThreadId || hasReachedTerminalLimit) return;
1001 dispatch({
1002 type: "NEW_THREAD_TERMINAL",
1003 threadId: activeThreadId,
1004 terminalId: `terminal-${crypto.randomUUID()}`,
1005 });
1016 newThreadTerminalAction(activeThreadId, `terminal-${crypto.randomUUID()}`);
10061017 setTerminalFocusRequestId((value) => value + 1);
1007 }, [activeThreadId, dispatch, hasReachedTerminalLimit]);
1018 }, [activeThreadId, hasReachedTerminalLimit, newThreadTerminalAction]);
10081019 const activateTerminal = useCallback(
10091020 (terminalId: string) => {
10101021 if (!activeThreadId) return;
1011 dispatch({
1012 type: "SET_THREAD_ACTIVE_TERMINAL",
1013 threadId: activeThreadId,
1014 terminalId,
1015 });
1022 setThreadActiveTerminal(activeThreadId, terminalId);
10161023 setTerminalFocusRequestId((value) => value + 1);
10171024 },
1018 [activeThreadId, dispatch],
1025 [activeThreadId, setThreadActiveTerminal],
10191026 );
10201027 const closeTerminal = useCallback(
10211028 (terminalId: string) => {
......@@ -1038,14 +1045,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
10381045 } else {
10391046 void fallbackExitWrite();
10401047 }
1041 dispatch({
1042 type: "CLOSE_THREAD_TERMINAL",
1043 threadId: activeThreadId,
1044 terminalId,
1045 });
1048 closeThreadTerminalAction(activeThreadId, terminalId);
10461049 setTerminalFocusRequestId((value) => value + 1);
10471050 },
1048 [activeThread?.terminalIds.length, activeThreadId, dispatch],
1051 [activeThread?.terminalIds.length, activeThreadId, closeThreadTerminalAction],
10491052 );
10501053 const runProjectScript = useCallback(
10511054 async (
......@@ -1079,23 +1082,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
10791082 ? `terminal-${crypto.randomUUID()}`
10801083 : baseTerminalId;
10811084
1082 dispatch({
1083 type: "SET_THREAD_TERMINAL_OPEN",
1084 threadId: activeThreadId,
1085 open: true,
1086 });
1085 setThreadTerminalOpen(activeThreadId, true);
10871086 if (shouldCreateNewTerminal) {
1088 dispatch({
1089 type: "NEW_THREAD_TERMINAL",
1090 threadId: activeThreadId,
1091 terminalId: targetTerminalId,
1092 });
1087 newThreadTerminalAction(activeThreadId, targetTerminalId);
10931088 } else {
1094 dispatch({
1095 type: "SET_THREAD_ACTIVE_TERMINAL",
1096 threadId: activeThreadId,
1097 terminalId: targetTerminalId,
1098 });
1089 setThreadActiveTerminal(activeThreadId, targetTerminalId);
10991090 }
11001091 setTerminalFocusRequestId((value) => value + 1);
11011092
......@@ -1126,14 +1117,23 @@ export default function ChatView({ threadId }: ChatViewProps) {
11261117 data: `${script.command}\r`,
11271118 });
11281119 } catch (error) {
1129 dispatch({
1130 type: "SET_ERROR",
1131 threadId: activeThreadId,
1132 error: error instanceof Error ? error.message : `Failed to run script "${script.name}".`,
1133 });
1120 setThreadErrorAction(
1121 activeThreadId,
1122 error instanceof Error ? error.message : `Failed to run script "${script.name}".`,
1123 );
11341124 }
11351125 },
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 ],
11371137 );
11381138 const persistProjectScripts = useCallback(
11391139 async (input: {
......@@ -1236,35 +1236,34 @@ export default function ChatView({ threadId }: ChatViewProps) {
12361236 );
12371237
12381238 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
1239 if (mode === state.runtimeMode) return;
1240 dispatch({ type: "SET_RUNTIME_MODE", mode });
1239 if (mode === runtimeMode) return;
1240 setRuntimeMode(mode);
12411241 scheduleComposerFocus();
12421242 const api = readNativeApi();
12431243 if (!api) return;
12441244
1245 const runningThreadIds = state.threads
1245 const runningThreadIds = useStore
1246 .getState()
1247 .threads
12461248 .filter((thread) => thread.session !== null && thread.session.status !== "closed")
12471249 .map((thread) => thread.id);
12481250
12491251 if (runningThreadIds.length === 0) return;
12501252
12511253 setIsSwitchingRuntimeMode(true);
1252 try {
1253 await Promise.all(
1254 runningThreadIds.map((threadId) =>
1255 api.orchestration
1256 .dispatchCommand({
1257 type: "thread.session.stop",
1258 commandId: newCommandId(),
1259 threadId,
1260 createdAt: new Date().toISOString(),
1261 })
1262 .catch(() => undefined),
1263 ),
1264 );
1265 } finally {
1266 setIsSwitchingRuntimeMode(false);
1267 }
1254 await Promise.all(
1255 runningThreadIds.map((threadId) =>
1256 api.orchestration
1257 .dispatchCommand({
1258 type: "thread.session.stop",
1259 commandId: newCommandId(),
1260 threadId,
1261 createdAt: new Date().toISOString(),
1262 })
1263 .catch(() => undefined),
1264 ),
1265 );
1266 setIsSwitchingRuntimeMode(false);
12681267 };
12691268
12701269 useEffect(() => {
......@@ -1642,11 +1641,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
16421641 event.preventDefault();
16431642 event.stopPropagation();
16441643 if (!activeThread?.terminalOpen) {
1645 dispatch({
1646 type: "SET_THREAD_TERMINAL_OPEN",
1647 threadId: activeThreadId,
1648 open: true,
1649 });
1644 setThreadTerminalOpen(activeThreadId, true);
16501645 }
16511646 splitTerminal();
16521647 return;
......@@ -1664,11 +1659,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
16641659 event.preventDefault();
16651660 event.stopPropagation();
16661661 if (!activeThread?.terminalOpen) {
1667 dispatch({
1668 type: "SET_THREAD_TERMINAL_OPEN",
1669 threadId: activeThreadId,
1670 open: true,
1671 });
1662 setThreadTerminalOpen(activeThreadId, true);
16721663 }
16731664 createNewTerminal();
16741665 return;
......@@ -1698,23 +1689,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
16981689 activeThreadId,
16991690 closeTerminal,
17001691 createNewTerminal,
1701 dispatch,
17021692 runProjectScript,
17031693 splitTerminal,
17041694 keybindings,
17051695 onToggleDiff,
1696 setThreadTerminalOpen,
17061697 toggleTerminalVisibility,
17071698 ]);
17081699
17091700 const setThreadError = useCallback(
17101701 (threadId: ThreadId | null, error: string | null) => {
17111702 if (!threadId) return;
1712 if (state.threads.some((thread) => thread.id === threadId)) {
1713 dispatch({
1714 type: "SET_ERROR",
1715 threadId,
1716 error,
1717 });
1703 if (useStore.getState().threads.some((thread) => thread.id === threadId)) {
1704 setThreadErrorAction(threadId, error);
17181705 return;
17191706 }
17201707 setLocalDraftErrorsByThreadId((existing) => {
......@@ -1727,7 +1714,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
17271714 };
17281715 });
17291716 },
1730 [dispatch, state.threads],
1717 [setThreadErrorAction],
17311718 );
17321719
17331720 const addComposerImages = (files: File[]) => {
......@@ -1868,9 +1855,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
18681855 activeThread.id,
18691856 err instanceof Error ? err.message : "Failed to revert thread state.",
18701857 );
1871 } finally {
1872 setIsRevertingCheckpoint(false);
18731858 }
1859 setIsRevertingCheckpoint(false);
18741860 },
18751861 [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
18761862 );
......@@ -1894,11 +1880,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
18941880 const shouldCreateWorktree =
18951881 isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;
18961882 if (shouldCreateWorktree && !activeThread.branch) {
1897 dispatch({
1898 type: "SET_ERROR",
1899 threadId: threadIdForSend,
1900 error: "Select a base branch before sending in New worktree mode.",
1901 });
1883 setThreadErrorAction(
1884 threadIdForSend,
1885 "Select a base branch before sending in New worktree mode.",
1886 );
19021887 return;
19031888 }
19041889
......@@ -1971,12 +1956,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
19711956 });
19721957 // Keep local thread state in sync immediately so terminal drawer opens
19731958 // with the worktree cwd/env instead of briefly using the project root.
1974 dispatch({
1975 type: "SET_THREAD_BRANCH",
1976 threadId: threadIdForSend,
1977 branch: result.worktree.branch,
1978 worktreePath: result.worktree.path,
1979 });
1959 setThreadBranchAction(
1960 threadIdForSend,
1961 result.worktree.branch,
1962 result.worktree.path,
1963 );
19801964 }
19811965 }
19821966
......@@ -2026,9 +2010,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
20262010
20272011 setSendPhase("sending-turn");
20282012 const turnAttachments = await turnAttachmentsPromise;
2029 const approvalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request";
2030 const sandboxMode =
2031 state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write";
2013 const approvalPolicy = runtimeMode === "full-access" ? "never" : "on-request";
2014 const sandboxMode = runtimeMode === "full-access" ? "danger-full-access" : "workspace-write";
20322015 await api.orchestration.dispatchCommand({
20332016 type: "thread.turn.start",
20342017 commandId: newCommandId(),
......@@ -2082,10 +2065,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
20822065 threadIdForSend,
20832066 err instanceof Error ? err.message : "Failed to send message.",
20842067 );
2085 } finally {
2086 sendInFlightRef.current = false;
2087 setSendPhase("idle");
20882068 }
2069 sendInFlightRef.current = false;
2070 setSendPhase("idle");
20892071 };
20902072
20912073 const onInterrupt = async () => {
......@@ -2117,16 +2099,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
21172099 createdAt: new Date().toISOString(),
21182100 });
21192101 } catch (err) {
2120 dispatch({
2121 type: "SET_ERROR",
2122 threadId: activeThreadId,
2123 error: err instanceof Error ? err.message : "Failed to submit approval decision.",
2124 });
2125 } finally {
2126 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
2102 setThreadErrorAction(
2103 activeThreadId,
2104 err instanceof Error ? err.message : "Failed to submit approval decision.",
2105 );
21272106 }
2107 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
21282108 },
2129 [activeThreadId, dispatch],
2109 [activeThreadId, setThreadErrorAction],
21302110 );
21312111
21322112 const onModelSelect = useCallback(
......@@ -2211,13 +2191,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
22112191 const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => {
22122192 setComposerHighlightedItemId(itemId);
22132193 }, []);
2214 const nudgeComposerMenuHighlight = useCallback((key: "ArrowDown" | "ArrowUp") => {
2215 const commandInput = composerCommandInputRef.current;
2216 if (!commandInput) return;
2217 commandInput.dispatchEvent(
2218 new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
2219 );
2220 }, []);
2194 const nudgeComposerMenuHighlight = useCallback(
2195 (key: "ArrowDown" | "ArrowUp") => {
2196 if (composerMenuItems.length === 0) {
2197 return;
2198 }
2199 const highlightedIndex = composerMenuItems.findIndex(
2200 (item) => item.id === composerHighlightedItemId,
2201 );
2202 const normalizedIndex =
2203 highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0;
2204 const offset = key === "ArrowDown" ? 1 : -1;
2205 const nextIndex =
2206 (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length;
2207 const nextItem = composerMenuItems[nextIndex];
2208 setComposerHighlightedItemId(nextItem?.id ?? null);
2209 },
2210 [composerHighlightedItemId, composerMenuItems],
2211 );
22212212 const isComposerMenuLoading =
22222213 composerTriggerKind === "path" &&
22232214 ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) ||
......@@ -2359,7 +2350,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
23592350
23602351 {/* Messages */}
23612352 <div
2362 ref={messagesScrollRef}
2353 ref={setMessagesScrollContainerRef}
23632354 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"
23642355 onScroll={onMessagesScroll}
23652356 >
......@@ -2369,7 +2360,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
23692360 isWorking={isWorking}
23702361 activeTurnInProgress={!latestTurnSettled}
23712362 activeTurnStartedAt={activeLatestTurn?.startedAt ?? null}
2372 scrollContainerRef={messagesScrollRef}
2363 scrollContainer={messagesScrollElement}
23732364 timelineEntries={timelineEntries}
23742365 completionDividerBeforeEntryId={completionDividerBeforeEntryId}
23752366 completionSummary={completionSummary}
......@@ -2414,7 +2405,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
24142405 triggerKind={composerTriggerKind}
24152406 onHighlightedItemChange={onComposerMenuItemHighlighted}
24162407 onSelect={onSelectComposerItem}
2417 commandInputRef={composerCommandInputRef}
24182408 />
24192409 </div>
24202410 )}
......@@ -2427,16 +2417,22 @@ export default function ChatView({ threadId }: ChatViewProps) {
24272417 className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background"
24282418 >
24292419 {image.previewUrl ? (
2430 <img
2431 src={image.previewUrl}
2432 alt={image.name}
2433 className="h-full w-full cursor-zoom-in object-cover"
2420 <button
2421 type="button"
2422 className="h-full w-full cursor-zoom-in"
2423 aria-label={`Preview ${image.name}`}
24342424 onClick={() => {
24352425 const preview = buildExpandedImagePreview(composerImages, image.id);
24362426 if (!preview) return;
24372427 setExpandedImage(preview);
24382428 }}
2439 />
2429 >
2430 <img
2431 src={image.previewUrl}
2432 alt={image.name}
2433 className="h-full w-full object-cover"
2434 />
2435 </button>
24402436 ) : (
24412437 <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70">
24422438 {image.name}
......@@ -2538,18 +2534,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
25382534 disabled={isSwitchingRuntimeMode}
25392535 onClick={() =>
25402536 void handleRuntimeModeChange(
2541 state.runtimeMode === "full-access" ? "approval-required" : "full-access",
2537 runtimeMode === "full-access" ? "approval-required" : "full-access",
25422538 )
25432539 }
25442540 title={
2545 state.runtimeMode === "full-access"
2541 runtimeMode === "full-access"
25462542 ? "Full access — click to require approvals"
25472543 : "Approval required — click for full access"
25482544 }
25492545 >
2550 {state.runtimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />}
2546 {runtimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />}
25512547 <span className="sr-only sm:not-sr-only">
2552 {state.runtimeMode === "full-access" ? "Full access" : "Supervised"}
2548 {runtimeMode === "full-access" ? "Full access" : "Supervised"}
25532549 </span>
25542550 </Button>
25552551 </div>
......@@ -2670,13 +2666,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
26702666 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
26712667 onActiveTerminalChange={activateTerminal}
26722668 onCloseTerminal={closeTerminal}
2673 onHeightChange={(height) =>
2674 dispatch({
2675 type: "SET_THREAD_TERMINAL_HEIGHT",
2676 threadId: activeThread.id,
2677 height,
2678 })
2679 }
2669 onHeightChange={(height) => setThreadTerminalHeight(activeThread.id, height)}
26802670 />
26812671 );
26822672 })()}
......@@ -2687,27 +2677,28 @@ export default function ChatView({ threadId }: ChatViewProps) {
26872677 role="dialog"
26882678 aria-modal="true"
26892679 aria-label="Expanded image preview"
2690 onClick={closeExpandedImage}
26912680 >
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 />
26922687 {expandedImage.images.length > 1 && (
26932688 <Button
26942689 type="button"
26952690 size="icon"
26962691 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"
26982693 aria-label="Previous image"
2699 onClick={(event) => {
2700 event.stopPropagation();
2694 onClick={() => {
27012695 navigateExpandedImage(-1);
27022696 }}
27032697 >
27042698 <ChevronLeftIcon className="size-5" />
27052699 </Button>
27062700 )}
2707 <div
2708 className="relative isolate max-h-[92vh] max-w-[92vw]"
2709 onClick={(event) => event.stopPropagation()}
2710 >
2701 <div className="relative isolate z-10 max-h-[92vh] max-w-[92vw]">
27112702 <Button
27122703 type="button"
27132704 size="icon-xs"
......@@ -2736,10 +2727,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
27362727 type="button"
27372728 size="icon"
27382729 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"
27402731 aria-label="Next image"
2741 onClick={(event) => {
2742 event.stopPropagation();
2732 onClick={() => {
27432733 navigateExpandedImage(1);
27442734 }}
27452735 >
......@@ -2950,7 +2940,7 @@ interface MessagesTimelineProps {
29502940 isWorking: boolean;
29512941 activeTurnInProgress: boolean;
29522942 activeTurnStartedAt: string | null;
2953 scrollContainerRef: React.RefObject<HTMLDivElement | null>;
2943 scrollContainer: HTMLDivElement | null;
29542944 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
29552945 completionDividerBeforeEntryId: string | null;
29562946 completionSummary: string | null;
......@@ -3004,7 +2994,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
30042994 isWorking,
30052995 activeTurnInProgress,
30062996 activeTurnStartedAt,
3007 scrollContainerRef,
2997 scrollContainer,
30082998 timelineEntries,
30092999 completionDividerBeforeEntryId,
30103000 completionSummary,
......@@ -3114,7 +3104,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
31143104
31153105 const rowVirtualizer = useVirtualizer({
31163106 count: virtualizedRowCount,
3117 getScrollElement: () => scrollContainerRef.current,
3107 getScrollElement: () => scrollContainer,
31183108 // Use stable row ids so virtual measurements do not leak across thread switches.
31193109 getItemKey: (index: number) => rows[index]?.id ?? index,
31203110 estimateSize: (index: number) => {
......@@ -3242,18 +3232,24 @@ const MessagesTimeline = memo(function MessagesTimeline({
32423232 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
32433233 >
32443234 {image.previewUrl ? (
3245 <img
3246 src={image.previewUrl}
3247 alt={image.name}
3248 className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"
3249 onLoad={onTimelineImageLoad}
3250 onError={onTimelineImageLoad}
3235 <button
3236 type="button"
3237 className="h-full w-full cursor-zoom-in"
3238 aria-label={`Preview ${image.name}`}
32513239 onClick={() => {
32523240 const preview = buildExpandedImagePreview(userImages, image.id);
32533241 if (!preview) return;
32543242 onImageExpand(preview);
32553243 }}
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>
32573253 ) : (
32583254 <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">
32593255 {image.name}
......@@ -3545,9 +3541,21 @@ const OpenInPicker = memo(function OpenInPicker({
35453541 ] satisfies { label: string; Icon: Icon; value: EditorId }[];
35463542 const primaryOption = options.find(({ value }) => value === lastEditor);
35473543
3548 const { state } = useStore();
3549 const activeThread = state.threads.find((t) => t.id === activeThreadId);
3550 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
3544 const activeThread = useStore(
3545 useCallback(
3546 (state) =>
3547 activeThreadId ? state.threads.find((thread) => thread.id === activeThreadId) : undefined,
3548 [activeThreadId],
3549 ),
3550 );
3551 const activeProjectId = activeThread?.projectId ?? null;
3552 const activeProject = useStore(
3553 useCallback(
3554 (state) =>
3555 activeProjectId ? state.projects.find((project) => project.id === activeProjectId) : undefined,
3556 [activeProjectId],
3557 ),
3558 );
35513559
35523560 const openInEditor = useCallback(
35533561 (editorId: EditorId | null) => {
apps/web/src/components/DiffPanel.tsx+29-11
......@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
44import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
55import { ThreadId, type TurnId } from "@t3tools/contracts";
66import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
7import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7import { type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
88import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
99import { cn } from "~/lib/utils";
1010import { readNativeApi } from "../nativeApi";
......@@ -156,7 +156,6 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
156156export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
157157 const navigate = useNavigate();
158158 const { resolvedTheme } = useTheme();
159 const { state } = useStore();
160159 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
161160 const patchViewportRef = useRef<HTMLDivElement>(null);
162161 const turnStripRef = useRef<HTMLDivElement>(null);
......@@ -168,8 +167,21 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
168167 });
169168 const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) });
170169 const activeThreadId = routeThreadId;
171 const activeThread = state.threads.find((thread) => thread.id === activeThreadId);
172 const activeProject = state.projects.find((project) => project.id === activeThread?.projectId);
170 const activeThread = useStore(
171 useCallback(
172 (state) =>
173 activeThreadId ? state.threads.find((thread) => thread.id === activeThreadId) : undefined,
174 [activeThreadId],
175 ),
176 );
177 const activeProjectId = activeThread?.projectId ?? null;
178 const activeProject = useStore(
179 useCallback(
180 (state) =>
181 activeProjectId ? state.projects.find((project) => project.id === activeProjectId) : undefined,
182 [activeProjectId],
183 ),
184 );
173185 const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;
174186 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
175187 useTurnDiffSummaries(activeThread);
......@@ -342,7 +354,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
342354 if (!element) return;
343355 element.scrollBy({ left: offset, behavior: "smooth" });
344356 }, []);
345 const onTurnStripWheel = useCallback((event: WheelEvent) => {
357 const onTurnStripWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {
346358 const element = turnStripRef.current;
347359 if (!element) return;
348360 if (element.scrollWidth <= element.clientWidth + 1) return;
......@@ -356,24 +368,26 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
356368 const element = turnStripRef.current;
357369 if (!element) return;
358370
359 updateTurnStripScrollState();
371 const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState());
360372 const onScroll = () => updateTurnStripScrollState();
361373
362374 element.addEventListener("scroll", onScroll, { passive: true });
363 element.addEventListener("wheel", onTurnStripWheel, { passive: false });
364375
365376 const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState());
366377 resizeObserver.observe(element);
367378
368379 return () => {
380 window.cancelAnimationFrame(frameId);
369381 element.removeEventListener("scroll", onScroll);
370 element.removeEventListener("wheel", onTurnStripWheel);
371382 resizeObserver.disconnect();
372383 };
373 }, [updateTurnStripScrollState, onTurnStripWheel]);
384 }, [updateTurnStripScrollState]);
374385
375386 useEffect(() => {
376 updateTurnStripScrollState();
387 const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState());
388 return () => {
389 window.cancelAnimationFrame(frameId);
390 };
377391 }, [orderedTurnDiffSummaries, selectedTurnId, updateTurnStripScrollState]);
378392
379393 useEffect(() => {
......@@ -422,7 +436,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
422436 >
423437 <ChevronRightIcon className="size-3.5" />
424438 </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 >
426444 <button
427445 type="button"
428446 className="shrink-0 rounded-md"
apps/web/src/components/Sidebar.tsx+66-58
......@@ -228,7 +228,10 @@ function ProjectFavicon({ cwd }: { cwd: string }) {
228228}
229229
230230export 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);
232235 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft);
233236 const getDraftThreadByProjectId = useComposerDraftStore(
234237 (store) => store.getDraftThreadByProjectId,
......@@ -260,23 +263,23 @@ export default function Sidebar() {
260263 const [isAddingProject, setIsAddingProject] = useState(false);
261264 const pendingApprovalByThreadId = useMemo(() => {
262265 const map = new Map<ThreadId, boolean>();
263 for (const thread of state.threads) {
266 for (const thread of threads) {
264267 map.set(thread.id, derivePendingApprovals(thread.activities).length > 0);
265268 }
266269 return map;
267 }, [state.threads]);
270 }, [threads]);
268271 const projectCwdById = useMemo(
269 () => new Map(state.projects.map((project) => [project.id, project.cwd] as const)),
270 [state.projects],
272 () => new Map(projects.map((project) => [project.id, project.cwd] as const)),
273 [projects],
271274 );
272275 const threadGitTargets = useMemo(
273276 () =>
274 state.threads.map((thread) => ({
277 threads.map((thread) => ({
275278 threadId: thread.id,
276279 branch: thread.branch,
277280 cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null,
278281 })),
279 [projectCwdById, state.threads],
282 [projectCwdById, threads],
280283 );
281284 const threadGitStatusCwds = useMemo(
282285 () => [
......@@ -414,7 +417,7 @@ export default function Sidebar() {
414417
415418 const focusMostRecentThreadForProject = useCallback(
416419 (projectId: ProjectId) => {
417 const latestThread = state.threads
420 const latestThread = threads
418421 .filter((thread) => thread.projectId === projectId)
419422 .toSorted((a, b) => {
420423 const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
......@@ -428,7 +431,7 @@ export default function Sidebar() {
428431 params: { threadId: latestThread.id },
429432 });
430433 },
431 [navigate, state.threads],
434 [navigate, threads],
432435 );
433436
434437 const addProjectFromPath = useCallback(
......@@ -438,34 +441,36 @@ export default function Sidebar() {
438441 const api = readNativeApi();
439442
440443 setIsAddingProject(true);
441 try {
442 if (!api) return;
443 const existing = state.projects.find((project) => project.cwd === cwd);
444 if (api) {
445 const existing = projects.find((project) => project.cwd === cwd);
444446 if (existing) {
445447 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 }
447467 }
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);
466468 }
469 setIsAddingProject(false);
470 setNewCwd("");
471 setAddingProject(false);
467472 },
468 [focusMostRecentThreadForProject, handleNewThread, isAddingProject, state.projects],
473 [focusMostRecentThreadForProject, handleNewThread, isAddingProject, projects],
469474 );
470475
471476 const handleAddProject = () => {
......@@ -476,13 +481,16 @@ export default function Sidebar() {
476481 const api = readNativeApi();
477482 if (!api || isPickingFolder) return;
478483 setIsPickingFolder(true);
484 let pickedPath: string | null = null;
479485 try {
480 const pickedPath = await api.dialogs.pickFolder();
481 if (!pickedPath) return;
486 pickedPath = await api.dialogs.pickFolder();
487 } catch {
488 // Ignore picker failures and leave the current thread selection unchanged.
489 }
490 if (pickedPath) {
482491 await addProjectFromPath(pickedPath);
483 } finally {
484 setIsPickingFolder(false);
485492 }
493 setIsPickingFolder(false);
486494 };
487495
488496 const handleThreadContextMenu = useCallback(
......@@ -497,12 +505,12 @@ export default function Sidebar() {
497505 position,
498506 );
499507 if (clicked === "mark-unread") {
500 dispatch({ type: "MARK_THREAD_UNREAD", threadId });
508 markThreadUnread(threadId);
501509 return;
502510 }
503511 if (clicked !== "delete") return;
504512
505 const thread = state.threads.find((t) => t.id === threadId);
513 const thread = threads.find((t) => t.id === threadId);
506514 if (!thread) return;
507515 if (appSettings.confirmThreadDelete) {
508516 const confirmed = await api.dialogs.confirm(
......@@ -515,8 +523,8 @@ export default function Sidebar() {
515523 return;
516524 }
517525 }
518 const threadProject = state.projects.find((project) => project.id === thread.projectId);
519 const orphanedWorktreePath = getOrphanedWorktreePathForThread(state.threads, threadId);
526 const threadProject = projects.find((project) => project.id === thread.projectId);
527 const orphanedWorktreePath = getOrphanedWorktreePathForThread(threads, threadId);
520528 const displayWorktreePath = orphanedWorktreePath
521529 ? formatWorktreePathForDisplay(orphanedWorktreePath)
522530 : null;
......@@ -553,7 +561,7 @@ export default function Sidebar() {
553561 }
554562
555563 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;
557565 await api.orchestration.dispatchCommand({
558566 type: "thread.delete",
559567 commandId: newCommandId(),
......@@ -602,12 +610,12 @@ export default function Sidebar() {
602610 appSettings.confirmThreadDelete,
603611 clearComposerDraftForThread,
604612 clearProjectDraftThreadById,
605 dispatch,
613 markThreadUnread,
606614 navigate,
607615 removeWorktreeMutation,
608616 routeThreadId,
609 state.projects,
610 state.threads,
617 projects,
618 threads,
611619 ],
612620 );
613621
......@@ -618,10 +626,10 @@ export default function Sidebar() {
618626 const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position);
619627 if (clicked !== "delete") return;
620628
621 const project = state.projects.find((entry) => entry.id === projectId);
629 const project = projects.find((entry) => entry.id === projectId);
622630 if (!project) return;
623631
624 const projectThreads = state.threads.filter((thread) => thread.projectId === projectId);
632 const projectThreads = threads.filter((thread) => thread.projectId === projectId);
625633 if (projectThreads.length > 0) {
626634 toastManager.add({
627635 type: "warning",
......@@ -661,20 +669,20 @@ export default function Sidebar() {
661669 clearComposerDraftForThread,
662670 clearProjectDraftThreadId,
663671 getDraftThreadByProjectId,
664 state.projects,
665 state.threads,
672 projects,
673 threads,
666674 ],
667675 );
668676
669677 useEffect(() => {
670678 const onWindowKeyDown = (event: KeyboardEvent) => {
671679 const activeThread = routeThreadId
672 ? state.threads.find((thread) => thread.id === routeThreadId)
680 ? threads.find((thread) => thread.id === routeThreadId)
673681 : undefined;
674682 const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null;
675683 if (isChatNewLocalShortcut(event, keybindings)) {
676684 const projectId =
677 activeThread?.projectId ?? activeDraftThread?.projectId ?? state.projects[0]?.id;
685 activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id;
678686 if (!projectId) return;
679687 event.preventDefault();
680688 void handleNewThread(projectId);
......@@ -683,7 +691,7 @@ export default function Sidebar() {
683691
684692 if (!isChatNewShortcut(event, keybindings)) return;
685693 const projectId =
686 activeThread?.projectId ?? activeDraftThread?.projectId ?? state.projects[0]?.id;
694 activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id;
687695 if (!projectId) return;
688696 event.preventDefault();
689697 void handleNewThread(projectId, {
......@@ -697,14 +705,14 @@ export default function Sidebar() {
697705 return () => {
698706 window.removeEventListener("keydown", onWindowKeyDown);
699707 };
700 }, [getDraftThread, handleNewThread, keybindings, routeThreadId, state.projects, state.threads]);
708 }, [getDraftThread, handleNewThread, keybindings, routeThreadId, projects, threads]);
701709
702710 const onCreateThreadClick = () => {
703 if (state.projects.length === 0) {
711 if (projects.length === 0) {
704712 setAddingProject(true);
705713 return;
706714 }
707 const firstProject = state.projects[0];
715 const firstProject = projects[0];
708716 if (firstProject) void handleNewThread(firstProject.id);
709717 };
710718
......@@ -753,8 +761,8 @@ export default function Sidebar() {
753761 <SidebarContent className="gap-0">
754762 <SidebarGroup className="px-2 py-2">
755763 <SidebarMenu>
756 {state.projects.map((project) => {
757 const visibleThreads = state.threads
764 {projects.map((project) => {
765 const visibleThreads = threads
758766 .filter((thread) => thread.projectId === project.id)
759767 .toSorted((a, b) => {
760768 const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
......@@ -769,7 +777,7 @@ export default function Sidebar() {
769777 open={project.expanded}
770778 onOpenChange={(open) => {
771779 if (open === project.expanded) return;
772 dispatch({ type: "TOGGLE_PROJECT", projectId: project.id });
780 toggleProject(project.id);
773781 }}
774782 >
775783 <SidebarMenuItem>
......@@ -929,7 +937,7 @@ export default function Sidebar() {
929937 })}
930938 </SidebarMenu>
931939
932 {state.projects.length === 0 && !addingProject && (
940 {projects.length === 0 && !addingProject && (
933941 <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60">
934942 No projects yet.
935943 <br />
apps/web/src/routes/__root.tsx+16-17
......@@ -123,14 +123,19 @@ function errorDetails(error: unknown): string {
123123}
124124
125125function 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);
127129 const queryClient = useQueryClient();
128130 const navigate = useNavigate();
129131 const pathname = useRouterState({ select: (state) => state.location.pathname });
130132 const pathnameRef = useRef(pathname);
131133 const lastConfigIssuesSignatureRef = useRef<string | null>(null);
132134 const handledBootstrapThreadIdRef = useRef<string | null>(null);
133 pathnameRef.current = pathname;
135
136 useEffect(() => {
137 pathnameRef.current = pathname;
138 }, [pathname]);
134139
135140 useEffect(() => {
136141 const api = readNativeApi();
......@@ -144,7 +149,7 @@ function EventRouter() {
144149 const snapshot = await api.orchestration.getSnapshot();
145150 if (disposed) return;
146151 latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
147 dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot });
152 syncServerReadModel(snapshot);
148153 if (pending) {
149154 pending = false;
150155 await flushSnapshotSync();
......@@ -157,14 +162,13 @@ function EventRouter() {
157162 return;
158163 }
159164 syncing = true;
165 pending = false;
160166 try {
161 pending = false;
162167 await flushSnapshotSync();
163168 } catch {
164169 // Keep prior state and wait for next domain event to trigger a resync.
165 } finally {
166 syncing = false;
167170 }
171 syncing = false;
168172 };
169173
170174 void syncSnapshot().catch(() => undefined);
......@@ -184,12 +188,11 @@ function EventRouter() {
184188 if (hasRunningSubprocess === null) {
185189 return;
186190 }
187 dispatch({
188 type: "SET_THREAD_TERMINAL_ACTIVITY",
189 threadId: ThreadId.makeUnsafe(event.threadId),
190 terminalId: event.terminalId,
191 setThreadTerminalActivity(
192 ThreadId.makeUnsafe(event.threadId),
193 event.terminalId,
191194 hasRunningSubprocess,
192 });
195 );
193196 });
194197 const unsubWelcome = onServerWelcome((payload) => {
195198 void (async () => {
......@@ -201,11 +204,7 @@ function EventRouter() {
201204 if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) {
202205 return;
203206 }
204 dispatch({
205 type: "SET_PROJECT_EXPANDED",
206 projectId: payload.bootstrapProjectId,
207 expanded: true,
208 });
207 setProjectExpanded(payload.bootstrapProjectId, true);
209208
210209 if (pathnameRef.current !== "/") {
211210 return;
......@@ -270,7 +269,7 @@ function EventRouter() {
270269 unsubWelcome();
271270 unsubServerConfigUpdated();
272271 };
273 }, [dispatch, navigate, queryClient]);
272 }, [navigate, queryClient, setProjectExpanded, setThreadTerminalActivity, syncServerReadModel]);
274273
275274 return null;
276275}
apps/web/src/routes/_chat.$threadId.tsx+7-5
......@@ -133,13 +133,15 @@ const DiffPanelInlineSidebar = (props: {
133133};
134134
135135function ChatThreadRouteView() {
136 const { state } = useStore();
136 const threadsHydrated = useStore((state) => state.threadsHydrated);
137137 const navigate = useNavigate();
138138 const threadId = Route.useParams({
139139 select: (params) => ThreadId.makeUnsafe(params.threadId),
140140 });
141141 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 );
143145 const draftThreadExists = useComposerDraftStore(
144146 (store) => Object.hasOwn(store.draftThreadsByThreadId, threadId),
145147 );
......@@ -167,7 +169,7 @@ function ChatThreadRouteView() {
167169 }, [navigate, threadId]);
168170
169171 useEffect(() => {
170 if (!state.threadsHydrated) {
172 if (!threadsHydrated) {
171173 return;
172174 }
173175
......@@ -175,9 +177,9 @@ function ChatThreadRouteView() {
175177 void navigate({ to: "/", replace: true });
176178 return;
177179 }
178 }, [navigate, routeThreadExists, state.threadsHydrated, threadId]);
180 }, [navigate, routeThreadExists, threadsHydrated, threadId]);
179181
180 if (!state.threadsHydrated || !routeThreadExists) {
182 if (!threadsHydrated || !routeThreadExists) {
181183 return null;
182184 }
183185
apps/web/src/routes/_chat.settings.tsx+18-18
......@@ -48,7 +48,8 @@ const RUNTIME_MODE_OPTIONS = [
4848
4949function SettingsRouteView() {
5050 const { theme, setTheme, resolvedTheme } = useTheme();
51 const { state, dispatch } = useStore();
51 const runtimeMode = useStore((state) => state.runtimeMode);
52 const setRuntimeMode = useStore((state) => state.setRuntimeMode);
5253 const { settings, defaults, updateSettings } = useAppSettings();
5354 const serverConfigQuery = useQuery(serverConfigQueryOptions());
5455 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);
......@@ -186,9 +187,10 @@ function SettingsRouteView() {
186187 </div>
187188
188189 <div className="space-y-4">
189 <label className="block space-y-1">
190 <label htmlFor="codex-binary-path" className="block space-y-1">
190191 <span className="text-xs font-medium text-foreground">Codex binary path</span>
191192 <Input
193 id="codex-binary-path"
192194 value={codexBinaryPath}
193195 onChange={(event) => updateSettings({ codexBinaryPath: event.target.value })}
194196 placeholder="codex"
......@@ -199,9 +201,10 @@ function SettingsRouteView() {
199201 </span>
200202 </label>
201203
202 <label className="block space-y-1">
204 <label htmlFor="codex-home-path" className="block space-y-1">
203205 <span className="text-xs font-medium text-foreground">CODEX_HOME path</span>
204206 <Input
207 id="codex-home-path"
205208 value={codexHomePath}
206209 onChange={(event) => updateSettings({ codexHomePath: event.target.value })}
207210 placeholder="/Users/you/.codex"
......@@ -242,16 +245,11 @@ function SettingsRouteView() {
242245 </div>
243246
244247 <div className="space-y-4">
245 <form
246 className="flex flex-col gap-3 sm:flex-row sm:items-start"
247 onSubmit={(event) => {
248 event.preventDefault();
249 addCustomModel();
250 }}
251 >
252 <label className="block flex-1 space-y-1">
248 <div className="flex flex-col gap-3 sm:flex-row sm:items-start">
249 <label htmlFor="custom-model-slug" className="block flex-1 space-y-1">
253250 <span className="text-xs font-medium text-foreground">Custom model slug</span>
254251 <Input
252 id="custom-model-slug"
255253 value={customModelInput}
256254 onChange={(event) => {
257255 setCustomModelInput(event.target.value);
......@@ -259,6 +257,11 @@ function SettingsRouteView() {
259257 setCustomModelError(null);
260258 }
261259 }}
260 onKeyDown={(event) => {
261 if (event.key !== "Enter") return;
262 event.preventDefault();
263 addCustomModel();
264 }}
262265 placeholder="your-model-slug"
263266 spellCheck={false}
264267 />
......@@ -267,10 +270,10 @@ function SettingsRouteView() {
267270 </span>
268271 </label>
269272
270 <Button className="sm:mt-6" type="submit">
273 <Button className="sm:mt-6" type="button" onClick={addCustomModel}>
271274 Add model
272275 </Button>
273 </form>
276 </div>
274277
275278 {customModelError ? (
276279 <p className="text-xs text-destructive">{customModelError}</p>
......@@ -333,7 +336,7 @@ function SettingsRouteView() {
333336
334337 <div className="space-y-2" role="radiogroup" aria-label="Runtime mode preference">
335338 {RUNTIME_MODE_OPTIONS.map((option) => {
336 const selected = state.runtimeMode === option.value;
339 const selected = runtimeMode === option.value;
337340 return (
338341 <button
339342 key={option.value}
......@@ -346,10 +349,7 @@ function SettingsRouteView() {
346349 : "border-border bg-background text-muted-foreground hover:bg-accent"
347350 }`}
348351 onClick={() => {
349 dispatch({
350 type: "SET_RUNTIME_MODE",
351 mode: option.value,
352 });
352 setRuntimeMode(option.value);
353353 }}
354354 >
355355 <span className="flex flex-col">
apps/web/src/store.ts+84-25
......@@ -1,12 +1,5 @@
1import {
2 type Dispatch,
3 type ReactNode,
4 createContext,
5 createElement,
6 useContext,
7 useEffect,
8 useReducer,
9} from "react";
1import { type ReactNode } from "react";
2import { create } from "zustand";
103
114import {
125 DEFAULT_MODEL,
......@@ -67,6 +60,29 @@ export interface AppState {
6760 runtimeMode: RuntimeMode;
6861}
6962
63interface 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
7086const PERSISTED_STATE_KEY = "t3code:renderer-state:v7";
7187const LEGACY_PERSISTED_STATE_KEYS = [
7288 "t3code:renderer-state:v6",
......@@ -804,23 +820,66 @@ export function reducer(state: AppState, action: Action): AppState {
804820 }
805821}
806822
807// ── Context ──────────────────────────────────────────────────────────
808
809const StoreContext = createContext<{
810 state: AppState;
811 dispatch: Dispatch<Action>;
812}>({ state: initialState, dispatch: () => {} });
813
814export function StoreProvider({ children }: { children: ReactNode }) {
815 const [state, dispatch] = useReducer(reducer, undefined, readPersistedState);
823function toAppState(store: AppStore): AppState {
824 return {
825 projects: store.projects,
826 threads: store.threads,
827 threadsHydrated: store.threadsHydrated,
828 runtimeMode: store.runtimeMode,
829 };
830}
816831
817 useEffect(() => {
818 persistState(state);
819 }, [state]);
832export const useStore = create<AppStore>((set, get) => {
833 const applyAction = (action: Action): void => {
834 const currentState = toAppState(get());
835 const nextState = reducer(currentState, action);
836 if (nextState === currentState) {
837 return;
838 }
839 persistState(nextState);
840 set(nextState);
841 };
820842
821 return createElement(StoreContext.Provider, { value: { state, dispatch } }, children);
822}
843 return {
844 ...readPersistedState(),
845 syncServerReadModel: (readModel) => applyAction({ type: "SYNC_SERVER_READ_MODEL", readModel }),
846 markThreadVisited: (threadId, visitedAt) =>
847 applyAction({
848 type: "MARK_THREAD_VISITED",
849 threadId,
850 ...(visitedAt === undefined ? {} : { visitedAt }),
851 }),
852 markThreadUnread: (threadId) => applyAction({ type: "MARK_THREAD_UNREAD", threadId }),
853 toggleProject: (projectId) => applyAction({ type: "TOGGLE_PROJECT", projectId }),
854 setThreadTerminalActivity: (threadId, terminalId, hasRunningSubprocess) =>
855 applyAction({
856 type: "SET_THREAD_TERMINAL_ACTIVITY",
857 threadId,
858 terminalId,
859 hasRunningSubprocess,
860 }),
861 setProjectExpanded: (projectId, expanded) =>
862 applyAction({ type: "SET_PROJECT_EXPANDED", projectId, expanded }),
863 toggleThreadTerminal: (threadId) => applyAction({ type: "TOGGLE_THREAD_TERMINAL", threadId }),
864 setThreadTerminalOpen: (threadId, open) =>
865 applyAction({ type: "SET_THREAD_TERMINAL_OPEN", threadId, open }),
866 setThreadTerminalHeight: (threadId, height) =>
867 applyAction({ type: "SET_THREAD_TERMINAL_HEIGHT", threadId, height }),
868 splitThreadTerminal: (threadId, terminalId) =>
869 applyAction({ type: "SPLIT_THREAD_TERMINAL", threadId, terminalId }),
870 newThreadTerminal: (threadId, terminalId) =>
871 applyAction({ type: "NEW_THREAD_TERMINAL", threadId, terminalId }),
872 setThreadActiveTerminal: (threadId, terminalId) =>
873 applyAction({ type: "SET_THREAD_ACTIVE_TERMINAL", threadId, terminalId }),
874 closeThreadTerminal: (threadId, terminalId) =>
875 applyAction({ type: "CLOSE_THREAD_TERMINAL", threadId, terminalId }),
876 setThreadError: (threadId, error) => applyAction({ type: "SET_ERROR", threadId, error }),
877 setThreadBranch: (threadId, branch, worktreePath) =>
878 applyAction({ type: "SET_THREAD_BRANCH", threadId, branch, worktreePath }),
879 setRuntimeMode: (mode) => applyAction({ type: "SET_RUNTIME_MODE", mode }),
880 };
881});
823882
824export function useStore() {
825 return useContext(StoreContext);
883export function StoreProvider({ children }: { children: ReactNode }) {
884 return children;
826885}