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 @@...@@ -1,6 +1,6 @@
1import type { GitBranch, ThreadId } from "@t3tools/contracts";1import type { GitBranch, ThreadId } from "@t3tools/contracts";
2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useEffect, useState } from "react";3import { useCallback, useEffect, useState } from "react";
44
5import { newCommandId } from "../lib/utils";5import { newCommandId } from "../lib/utils";
6import {6import {
...@@ -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();
5062
51 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);63 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
52 const [branchQuery, setBranchQuery] = useState("");64 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);
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 ]);
140144
141 useEffect(() => {
142 if (isBranchMenuOpen) return;
143 setBranchQuery("");
144 }, [isBranchMenuOpen]);
145
146 // ── Helpers ───────────────────────────────────────────────────────────145 // ── Helpers ───────────────────────────────────────────────────────────
147146
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 };
152151
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 <Combobox281 <Combobox
283 items={branchPickerItems}282 items={branchPickerItems}
284 autoHighlight283 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";
101import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";101import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
102import { Badge } from "./ui/badge";102import { Badge } from "./ui/badge";
103import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";103import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
104import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";104import { Command, CommandItem, CommandList } from "./ui/command";
105import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";105import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
106import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";106import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";
107import {107import {
...@@ -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 );
345345 const failed = failedIconUrl === iconUrl;
346 useEffect(() => {
347 setFailed(false);
348 }, [iconUrl]);
349346
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 <Command413 <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 <ComposerCommandMenuItem424 <ComposerCommandMenuItem
...@@ -455,7 +448,17 @@ interface ChatViewProps {...@@ -455,7 +448,17 @@ interface ChatViewProps {
455}448}
456449
457export default function ChatView({ threadId }: ChatViewProps) {450export 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 }, []);
526547
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 );
557578
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 );
585611
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;
607633
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 ]);
619642
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;
10811084
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);
11011092
...@@ -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 );
12371237
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;
12441244
1245 const runningThreadIds = state.threads1245 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);
12481250
1249 if (runningThreadIds.length === 0) return;1251 if (runningThreadIds.length === 0) return;
12501252
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.orchestration1257 .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 };
12691268
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 ]);
17081699
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 );
17321719
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 }
19041889
...@@ -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 opens1957 // 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 }
19821966
...@@ -2026,9 +2010,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2026,9 +2010,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
20262010
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 };
20902072
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 );
21312111
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) {
23592350
2360 {/* Messages */}2351 {/* Messages */}
2361 <div2352 <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 <img2420 <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 <Button2688 <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 <div2701 <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 <Button2702 <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({
31143104
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 <img3235 <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);
35473543
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 );
35513559
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";
4import { useNavigate, useParams, useSearch } from "@tanstack/react-router";4import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
5import { ThreadId, type TurnId } from "@t3tools/contracts";5import { ThreadId, type TurnId } from "@t3tools/contracts";
6import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";6import { 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";
8import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";8import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
9import { cn } from "~/lib/utils";9import { cn } from "~/lib/utils";
10import { readNativeApi } from "../nativeApi";10import { readNativeApi } from "../nativeApi";
...@@ -156,7 +156,6 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";...@@ -156,7 +156,6 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
156export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {156export 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;
358370
359 updateTurnStripScrollState();371 const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState());
360 const onScroll = () => updateTurnStripScrollState();372 const onScroll = () => updateTurnStripScrollState();
361373
362 element.addEventListener("scroll", onScroll, { passive: true });374 element.addEventListener("scroll", onScroll, { passive: true });
363 element.addEventListener("wheel", onTurnStripWheel, { passive: false });
364375
365 const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState());376 const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState());
366 resizeObserver.observe(element);377 resizeObserver.observe(element);
367378
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]);
374385
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]);
378392
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 <button444 <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}
229229
230export default function Sidebar() {230export 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() {
414417
415 const focusMostRecentThreadForProject = useCallback(418 const focusMostRecentThreadForProject = useCallback(
416 (projectId: ProjectId) => {419 (projectId: ProjectId) => {
417 const latestThread = state.threads420 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 );
433436
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();
439442
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 );
470475
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 };
487495
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;
504512
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 = orphanedWorktreePath528 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 }
554562
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 );
613621
...@@ -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;
620628
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;
623631
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 );
668676
669 useEffect(() => {677 useEffect(() => {
670 const onWindowKeyDown = (event: KeyboardEvent) => {678 const onWindowKeyDown = (event: KeyboardEvent) => {
671 const activeThread = routeThreadId679 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() {
683691
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]);
701709
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 };
710718
...@@ -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.threads765 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>
931939
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}
124124
125function EventRouter() {125function 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]);
134139
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 };
169173
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 });
209208
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]);
274273
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};
134134
135function ChatThreadRouteView() {135function 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]);
168170
169 useEffect(() => {171 useEffect(() => {
170 if (!state.threadsHydrated) {172 if (!threadsHydrated) {
171 return;173 return;
172 }174 }
173175
...@@ -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]);
179181
180 if (!state.threadsHydrated || !routeThreadExists) {182 if (!threadsHydrated || !routeThreadExists) {
181 return null;183 return null;
182 }184 }
183185
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 = [
4848
49function SettingsRouteView() {49function 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>
187188
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 <Input192 <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>
201203
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 <Input206 <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>
243246
244 <div className="space-y-4">247 <div className="space-y-4">
245 <form248 <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 <Input251 <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>
269272
270 <Button className="sm:mt-6" type="submit">273 <Button className="sm:mt-6" type="button" onClick={addCustomModel}>
271 Add model274 Add model
272 </Button>275 </Button>
273 </form>276 </div>
274277
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() {
333336
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 <button341 <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 @@
1import {1import { type ReactNode } from "react";
2 type Dispatch,2import { create } from "zustand";
3 type ReactNode,
4 createContext,
5 createElement,
6 useContext,
7 useEffect,
8 useReducer,
9} from "react";
103
11import {4import {
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}
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
70const PERSISTED_STATE_KEY = "t3code:renderer-state:v7";86const PERSISTED_STATE_KEY = "t3code:renderer-state:v7";
71const LEGACY_PERSISTED_STATE_KEYS = [87const 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}
806822
807// ── Context ──────────────────────────────────────────────────────────823function toAppState(store: AppStore): AppState {
808824 return {
809const 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,
813829 };
814export function StoreProvider({ children }: { children: ReactNode }) {830}
815 const [state, dispatch] = useReducer(reducer, undefined, readPersistedState);
816831
817 useEffect(() => {832export 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 };
820842
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});
823882
824export function useStore() {883export function StoreProvider({ children }: { children: ReactNode }) {
825 return useContext(StoreContext);884 return children;
826}885}