authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 11:27:37-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 13:13:19-08:00
log4fca60469dfc5dab7d8d742e965f4ab260f20c3c
treefb081e535e6b15919e1f65b85a2829408ecff999
parentafcd824ae9ca93672fda317562e0076ce8918c89

Reset ChatView per thread and simplify derived state handling

- Mount `ChatView` with `key={threadId}` to fully reset UI state on thread switch - Replace effect-driven local state with direct derived values and cleaner cleanup paths - Simplify async command/script flows and minor context memo handling in sidebar/ui components

5 files changed, 168 insertions(+), 213 deletions(-)

apps/web/src/components/ChatView.tsx+133-177
......@@ -485,7 +485,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
485485 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
486486 const [isConnecting, _setIsConnecting] = useState(false);
487487 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
488 const [envMode, setEnvMode] = useState<DraftThreadEnvMode>("local");
489488 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
490489 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
491490 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
......@@ -505,7 +504,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
505504 const textareaRef = useRef<HTMLTextAreaElement>(null);
506505 const composerFormRef = useRef<HTMLFormElement>(null);
507506 const composerFormHeightRef = useRef(0);
508 const composerCommandInputRef = useRef<HTMLInputElement>(null);
509507 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
510508 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
511509 const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef<Record<string, number>>({});
......@@ -558,7 +556,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
558556
559557 const serverThread = threads.find((t) => t.id === threadId);
560558 const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId);
561 const localDraftError = localDraftErrorsByThreadId[threadId] ?? null;
559 const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null);
562560 const localDraftThread = useMemo(
563561 () =>
564562 draftThread
......@@ -584,19 +582,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
584582 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
585583 const activeProject = projects.find((p) => p.id === activeThread?.projectId);
586584
587 useEffect(() => {
588 if (!serverThread) {
589 return;
590 }
591 setLocalDraftErrorsByThreadId((existing) => {
592 if (!(threadId in existing)) {
593 return existing;
594 }
595 const { [threadId]: _removed, ...rest } = existing;
596 return rest as Record<ThreadId, string | null>;
597 });
598 }, [serverThread, threadId]);
599
600585 useEffect(() => {
601586 if (!activeThread?.id) return;
602587 if (!latestTurnSettled) return;
......@@ -618,27 +603,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
618603 const baseThreadModel = resolveModelSlug(activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL);
619604 const selectedModel = resolveModelSlug(composerDraft.model ?? baseThreadModel);
620605 const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING;
621 const modelOptions = useMemo(
622 () => getAppModelOptions(settings.customCodexModels, selectedModel),
623 [selectedModel, settings.customCodexModels],
624 );
606 const modelOptions = getAppModelOptions(settings.customCodexModels, selectedModel);
625607 const phase = derivePhase(activeThread?.session ?? null);
626608 const isSendBusy = sendPhase !== "idle";
627609 const isPreparingWorktree = sendPhase === "preparing-worktree";
628610 const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
629611 const nowIso = new Date(nowTick).toISOString();
630612 const threadActivities = activeThread?.activities ?? [];
631 const workLogEntries = useMemo(
632 () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined),
633 [activeLatestTurn?.turnId, threadActivities],
634 );
635 const latestTurnHasToolActivity = useMemo(() => {
636 return hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId);
637 }, [activeLatestTurn?.turnId, threadActivities]);
638 const pendingApprovals = useMemo(
639 () => derivePendingApprovals(threadActivities),
640 [threadActivities],
641 );
613 const workLogEntries = deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined);
614 const latestTurnHasToolActivity = hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId);
615 const pendingApprovals = derivePendingApprovals(threadActivities);
642616 useEffect(() => {
643617 attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;
644618 }, [attachmentPreviewHandoffByMessageId]);
......@@ -655,6 +629,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
655629 attachmentPreviewHandoffByMessageIdRef.current = {};
656630 setAttachmentPreviewHandoffByMessageId({});
657631 }, []);
632 useEffect(() => {
633 return () => {
634 clearAttachmentPreviewHandoffs();
635 };
636 }, [clearAttachmentPreviewHandoffs]);
658637 const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => {
659638 if (previewUrls.length === 0) return;
660639
......@@ -686,14 +665,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
686665 }
687666 setAttachmentPreviewHandoffByMessageId((existing) => {
688667 if (!(messageId in existing)) return existing;
689 const { [messageId]: _removed, ...rest } = existing;
690 attachmentPreviewHandoffByMessageIdRef.current = rest;
691 return rest;
668 const next = { ...existing };
669 delete next[messageId];
670 attachmentPreviewHandoffByMessageIdRef.current = next;
671 return next;
692672 });
693673 delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId];
694674 }, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS);
695675 }, []);
696 const timelineMessages = useMemo(() => {
676 const timelineMessages = (() => {
697677 const serverMessages = activeThread?.messages ?? [];
698678 const serverMessagesWithPreviewHandoff =
699679 Object.keys(attachmentPreviewHandoffByMessageId).length === 0
......@@ -737,11 +717,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
737717 return serverMessagesWithPreviewHandoff;
738718 }
739719 return [...serverMessagesWithPreviewHandoff, ...pendingMessages];
740 }, [activeThread?.messages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]);
741 const timelineEntries = useMemo(
742 () => deriveTimelineEntries(timelineMessages, workLogEntries),
743 [timelineMessages, workLogEntries],
744 );
720 })();
721 const timelineEntries = deriveTimelineEntries(timelineMessages, workLogEntries);
745722 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
746723 useTurnDiffSummaries(activeThread);
747724 const turnDiffSummaryByAssistantMessageId = useMemo(() => {
......@@ -752,7 +729,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
752729 }
753730 return byMessageId;
754731 }, [turnDiffSummaries]);
755 const revertTurnCountByUserMessageId = useMemo(() => {
732 const revertTurnCountByUserMessageId = (() => {
756733 const byUserMessageId = new Map<MessageId, number>();
757734 for (let index = 0; index < timelineEntries.length; index += 1) {
758735 const entry = timelineEntries[index];
......@@ -783,9 +760,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
783760 }
784761
785762 return byUserMessageId;
786 }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
763 })();
787764
788 const completionSummary = useMemo(() => {
765 const completionSummary = (() => {
789766 if (!latestTurnSettled) return null;
790767 if (!activeLatestTurn?.startedAt) return null;
791768 if (!activeLatestTurn.completedAt) return null;
......@@ -793,13 +770,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
793770
794771 const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt);
795772 return elapsed ? `Worked for ${elapsed}` : null;
796 }, [
797 activeLatestTurn?.completedAt,
798 activeLatestTurn?.startedAt,
799 latestTurnHasToolActivity,
800 latestTurnSettled,
801 ]);
802 const completionDividerBeforeEntryId = useMemo(() => {
773 })();
774 const completionDividerBeforeEntryId = (() => {
803775 if (!latestTurnSettled) return null;
804776 if (!activeLatestTurn?.startedAt) return null;
805777 if (!activeLatestTurn.completedAt) return null;
......@@ -823,13 +795,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
823795 }
824796 }
825797 return inRangeMatch ?? fallbackMatch;
826 }, [
827 activeLatestTurn?.completedAt,
828 activeLatestTurn?.startedAt,
829 completionSummary,
830 latestTurnSettled,
831 timelineEntries,
832 ]);
798 })();
833799 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
834800 const composerTrigger = useMemo(
835801 () => detectComposerTrigger(prompt, composerCursor),
......@@ -918,15 +884,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
918884 [nonPersistedComposerImageIds],
919885 );
920886 const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;
887 const activeProjectCwd = activeProject?.cwd ?? null;
888 const activeThreadWorktreePath = activeThread?.worktreePath ?? null;
921889 const threadTerminalRuntimeEnv = useMemo(() => {
922 if (!activeProject?.cwd) return {};
890 if (!activeProjectCwd) return {};
923891 return projectScriptRuntimeEnv({
924892 project: {
925 cwd: activeProject.cwd,
893 cwd: activeProjectCwd,
926894 },
927 worktreePath: activeThread?.worktreePath ?? null,
895 worktreePath: activeThreadWorktreePath,
928896 });
929 }, [activeProject?.cwd, activeThread?.worktreePath]);
897 }, [activeProjectCwd, activeThreadWorktreePath]);
930898 // Default true while loading to avoid toolbar flicker.
931899 const isGitRepo = branchesQuery.data?.isRepo ?? true;
932900 const splitTerminalShortcutLabel = useMemo(
......@@ -1109,20 +1077,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
11091077 worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null,
11101078 ...(options?.env ? { extraEnv: options.env } : {}),
11111079 });
1080 const openTerminalInput: Parameters<typeof api.terminal.open>[0] = shouldCreateNewTerminal
1081 ? {
1082 threadId: activeThreadId,
1083 terminalId: targetTerminalId,
1084 cwd: targetCwd,
1085 env: runtimeEnv,
1086 cols: SCRIPT_TERMINAL_COLS,
1087 rows: SCRIPT_TERMINAL_ROWS,
1088 }
1089 : {
1090 threadId: activeThreadId,
1091 terminalId: targetTerminalId,
1092 cwd: targetCwd,
1093 env: runtimeEnv,
1094 };
11121095
11131096 try {
1114 await api.terminal.open({
1115 threadId: activeThreadId,
1116 terminalId: targetTerminalId,
1117 cwd: targetCwd,
1118 env: runtimeEnv,
1119 ...(shouldCreateNewTerminal
1120 ? {
1121 cols: SCRIPT_TERMINAL_COLS,
1122 rows: SCRIPT_TERMINAL_ROWS,
1123 }
1124 : {}),
1125 });
1097 await api.terminal.open(openTerminalInput);
11261098 await api.terminal.write({
11271099 threadId: activeThreadId,
11281100 terminalId: targetTerminalId,
......@@ -1140,12 +1112,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
11401112 activeThread,
11411113 activeThreadId,
11421114 gitCwd,
1143 isLocalDraftThread,
11441115 isServerThread,
11451116 setTerminalOpen,
11461117 setThreadError,
11471118 storeNewTerminal,
11481119 storeSetActiveTerminal,
1120 terminalState.activeTerminalId,
1121 terminalState.runningTerminalIds,
1122 terminalState.terminalIds,
11491123 ],
11501124 );
11511125 const persistProjectScripts = useCallback(
......@@ -1369,24 +1343,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
13691343 scheduleStickToBottom();
13701344 }, [phase, scheduleStickToBottom, timelineEntries]);
13711345
1372 useEffect(() => {
1373 setExpandedWorkGroups({});
1374 }, [activeThread?.id]);
1375
1376 useEffect(() => {
1377 if (!composerMenuOpen) {
1378 setComposerHighlightedItemId(null);
1379 return;
1380 }
1381 setComposerHighlightedItemId((existing) =>
1382 existing && composerMenuItems.some((item) => item.id === existing) ? existing : null,
1383 );
1384 }, [composerMenuItems, composerMenuOpen]);
1385
1386 useEffect(() => {
1387 setIsRevertingCheckpoint(false);
1388 }, [activeThread?.id]);
1389
13901346 useEffect(() => {
13911347 if (!activeThread?.id || terminalState.terminalOpen) return;
13921348 const frame = window.requestAnimationFrame(() => {
......@@ -1402,16 +1358,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
14021358 }, [composerImages]);
14031359
14041360 useEffect(() => {
1405 if (!activeThread?.id) {
1406 clearAttachmentPreviewHandoffs();
1407 setOptimisticUserMessages((existing) => {
1408 for (const message of existing) {
1409 revokeUserMessagePreviewUrls(message);
1410 }
1411 return [];
1412 });
1413 return;
1414 }
1361 if (!activeThread?.id) return;
14151362 if (activeThread.messages.length === 0) {
14161363 return;
14171364 }
......@@ -1420,7 +1367,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
14201367 if (removedMessages.length === 0) {
14211368 return;
14221369 }
1423 setOptimisticUserMessages((existing) => existing.filter((message) => !serverIds.has(message.id)));
1370 const timer = window.setTimeout(() => {
1371 setOptimisticUserMessages((existing) =>
1372 existing.filter((message) => !serverIds.has(message.id)),
1373 );
1374 }, 0);
14241375 for (const removedMessage of removedMessages) {
14251376 const previewUrls = collectUserMessageBlobPreviewUrls(removedMessage);
14261377 if (previewUrls.length > 0) {
......@@ -1429,10 +1380,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
14291380 }
14301381 revokeUserMessagePreviewUrls(removedMessage);
14311382 }
1383 return () => {
1384 window.clearTimeout(timer);
1385 };
14321386 }, [
14331387 activeThread?.id,
14341388 activeThread?.messages,
1435 clearAttachmentPreviewHandoffs,
14361389 handoffAttachmentPreviews,
14371390 optimisticUserMessages,
14381391 ]);
......@@ -1441,21 +1394,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
14411394 promptRef.current = prompt;
14421395 }, [prompt]);
14431396
1444 useEffect(() => {
1445 clearAttachmentPreviewHandoffs();
1446 setOptimisticUserMessages((existing) => {
1447 for (const message of existing) {
1448 revokeUserMessagePreviewUrls(message);
1449 }
1450 return [];
1451 });
1452 setSendPhase("idle");
1453 setComposerHighlightedItemId(null);
1454 dragDepthRef.current = 0;
1455 setIsDragOverComposer(false);
1456 setExpandedImage(null);
1457 }, [clearAttachmentPreviewHandoffs, threadId]);
1458
14591397 useEffect(() => {
14601398 let cancelled = false;
14611399 void (async () => {
......@@ -1463,9 +1401,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
14631401 clearComposerDraftPersistedAttachments(threadId);
14641402 return;
14651403 }
1404 const getPersistedAttachmentsForThread = () =>
1405 useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? [];
14661406 try {
1467 const currentPersistedAttachments =
1468 useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? [];
1407 const currentPersistedAttachments = getPersistedAttachmentsForThread();
14691408 const existingPersistedById = new Map(
14701409 currentPersistedAttachments.map((attachment) => [attachment.id, attachment]),
14711410 );
......@@ -1497,8 +1436,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
14971436 syncComposerDraftPersistedAttachments(threadId, serialized);
14981437 } catch {
14991438 const currentImageIds = new Set(composerImages.map((image) => image.id));
1500 const fallbackPersistedAttachments =
1501 useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? [];
1439 const fallbackPersistedAttachments = getPersistedAttachmentsForThread();
15021440 const fallbackPersistedIds = fallbackPersistedAttachments
15031441 .map((attachment) => attachment.id)
15041442 .filter((id) => currentImageIds.has(id));
......@@ -1571,19 +1509,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
15711509 }, [closeExpandedImage, expandedImage, navigateExpandedImage]);
15721510
15731511 const activeWorktreePath = activeThread?.worktreePath;
1574
1575 useEffect(() => {
1576 if (!activeThread?.id) return;
1577 if (activeWorktreePath) {
1578 setEnvMode("worktree");
1579 return;
1580 }
1581 if (isLocalDraftThread) {
1582 setEnvMode(draftThread?.envMode ?? "local");
1583 return;
1584 }
1585 setEnvMode("local");
1586 }, [activeThread?.id, activeWorktreePath, draftThread?.envMode, isLocalDraftThread]);
1512 const envMode: DraftThreadEnvMode = activeWorktreePath
1513 ? "worktree"
1514 : isLocalDraftThread
1515 ? (draftThread?.envMode ?? "local")
1516 : "local";
15871517
15881518 // Auto-resize textarea
15891519 useEffect(() => {
......@@ -1609,7 +1539,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
16091539 const current = Boolean(terminalState.terminalOpen);
16101540
16111541 if (!previous && current) {
1612 setTerminalFocusRequestId((value) => value + 1);
1542 terminalOpenByThreadRef.current[activeThreadId] = current;
1543 return;
16131544 } else if (previous && !current) {
16141545 terminalOpenByThreadRef.current[activeThreadId] = current;
16151546 const frame = window.requestAnimationFrame(() => {
......@@ -1922,7 +1853,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
19221853 let turnStartSucceeded = false;
19231854 let nextThreadBranch = activeThread.branch;
19241855 let nextThreadWorktreePath = activeThread.worktreePath;
1925 try {
1856 await (async () => {
19261857 // On first message: lock in branch + create worktree if needed.
19271858 if (baseBranchForWorktree) {
19281859 setSendPhase("preparing-worktree");
......@@ -1948,12 +1879,29 @@ export default function ChatView({ threadId }: ChatViewProps) {
19481879 }
19491880 }
19501881
1951 const titleSeed =
1952 trimmed ||
1953 (composerImagesSnapshot.length > 0
1954 ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
1955 : "New thread");
1882 let firstComposerImageName: string | null = null;
1883 if (composerImagesSnapshot.length > 0) {
1884 const firstComposerImage = composerImagesSnapshot[0];
1885 if (firstComposerImage) {
1886 firstComposerImageName = firstComposerImage.name;
1887 }
1888 }
1889 let titleSeed = trimmed;
1890 if (!titleSeed) {
1891 if (firstComposerImageName) {
1892 titleSeed = `Image: ${firstComposerImageName}`;
1893 } else {
1894 titleSeed = "New thread";
1895 }
1896 }
19561897 const title = truncateTitle(titleSeed);
1898 let threadCreateModel = selectedModel;
1899 if (!threadCreateModel) {
1900 threadCreateModel = activeProject.model;
1901 }
1902 if (!threadCreateModel) {
1903 threadCreateModel = DEFAULT_MODEL;
1904 }
19571905
19581906 if (isLocalDraftThread) {
19591907 await api.orchestration.dispatchCommand({
......@@ -1962,7 +1910,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
19621910 threadId: threadIdForSend,
19631911 projectId: activeProject.id,
19641912 title,
1965 model: selectedModel ?? activeProject.model ?? DEFAULT_MODEL,
1913 model: threadCreateModel,
19661914 branch: nextThreadBranch,
19671915 worktreePath: nextThreadWorktreePath,
19681916 createdAt: activeThread.createdAt,
......@@ -1970,16 +1918,30 @@ export default function ChatView({ threadId }: ChatViewProps) {
19701918 createdServerThreadForLocalDraft = true;
19711919 }
19721920
1973 const setupScript = baseBranchForWorktree
1974 ? setupProjectScript(activeProject.scripts)
1975 : null;
1976 if (setupScript && (isServerThread || createdServerThreadForLocalDraft)) {
1977 await runProjectScript(setupScript, {
1978 worktreePath: nextThreadWorktreePath,
1979 rememberAsLastInvoked: false,
1980 allowLocalDraftThread: createdServerThreadForLocalDraft,
1981 ...(nextThreadWorktreePath ? { cwd: nextThreadWorktreePath } : {}),
1982 });
1921 let setupScript: ProjectScript | null = null;
1922 if (baseBranchForWorktree) {
1923 setupScript = setupProjectScript(activeProject.scripts);
1924 }
1925 if (setupScript) {
1926 let shouldRunSetupScript = false;
1927 if (isServerThread) {
1928 shouldRunSetupScript = true;
1929 } else {
1930 if (createdServerThreadForLocalDraft) {
1931 shouldRunSetupScript = true;
1932 }
1933 }
1934 if (shouldRunSetupScript) {
1935 const setupScriptOptions: Parameters<typeof runProjectScript>[1] = {
1936 worktreePath: nextThreadWorktreePath,
1937 rememberAsLastInvoked: false,
1938 allowLocalDraftThread: createdServerThreadForLocalDraft,
1939 };
1940 if (nextThreadWorktreePath) {
1941 setupScriptOptions.cwd = nextThreadWorktreePath;
1942 }
1943 await runProjectScript(setupScript, setupScriptOptions);
1944 }
19831945 }
19841946
19851947 // Auto-title from first message
......@@ -2018,7 +1980,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
20181980 if (isFirstMessage) {
20191981 clearDraftThread(threadIdForSend);
20201982 }
2021 } catch (err) {
1983 })().catch(async (err: unknown) => {
20221984 if (createdServerThreadForLocalDraft && !turnStartSucceeded) {
20231985 await api.orchestration
20241986 .dispatchCommand({
......@@ -2050,7 +2012,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
20502012 threadIdForSend,
20512013 err instanceof Error ? err.message : "Failed to send message.",
20522014 );
2053 }
2015 });
20542016 sendInFlightRef.current = false;
20552017 setSendPhase("idle");
20562018 };
......@@ -2074,23 +2036,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
20742036 setRespondingRequestIds((existing) =>
20752037 existing.includes(requestId) ? existing : [...existing, requestId],
20762038 );
2077 try {
2078 await api.orchestration.dispatchCommand({
2039 await api.orchestration
2040 .dispatchCommand({
20792041 type: "thread.approval.respond",
20802042 commandId: newCommandId(),
20812043 threadId: activeThreadId,
20822044 requestId,
20832045 decision,
20842046 createdAt: new Date().toISOString(),
2047 })
2048 .catch((err: unknown) => {
2049 setStoreThreadError(
2050 activeThreadId,
2051 err instanceof Error ? err.message : "Failed to submit approval decision.",
2052 );
20852053 });
2086 } catch (err) {
2087 setStoreThreadError(
2088 activeThreadId,
2089 err instanceof Error ? err.message : "Failed to submit approval decision.",
2090 );
2091 } finally {
2092 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
2093 }
20942054 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
20952055 },
20962056 [activeThreadId, setStoreThreadError],
......@@ -2130,7 +2090,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
21302090 );
21312091 const onEnvModeChange = useCallback(
21322092 (mode: DraftThreadEnvMode) => {
2133 setEnvMode(mode);
21342093 if (isLocalDraftThread) {
21352094 setDraftThreadContext(threadId, { envMode: mode });
21362095 }
......@@ -2260,16 +2219,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
22602219 },
22612220 [navigate, threadId],
22622221 );
2263 const onRevertUserMessage = useCallback(
2264 (messageId: MessageId) => {
2265 const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);
2266 if (typeof targetTurnCount !== "number") {
2267 return;
2268 }
2269 void onRevertToTurnCount(targetTurnCount);
2270 },
2271 [onRevertToTurnCount, revertTurnCountByUserMessageId],
2272 );
2222 const onRevertUserMessage = (messageId: MessageId) => {
2223 const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);
2224 if (typeof targetTurnCount !== "number") {
2225 return;
2226 }
2227 void onRevertToTurnCount(targetTurnCount);
2228 };
22732229
22742230 // Empty state: no active thread
22752231 if (!activeThread) {
apps/web/src/components/Sidebar.tsx+29-26
......@@ -443,38 +443,41 @@ export default function Sidebar() {
443443 const cwd = rawCwd.trim();
444444 if (!cwd || isAddingProject) return;
445445 const api = readNativeApi();
446 if (!api) return;
446447
447448 setIsAddingProject(true);
448 try {
449 if (!api) return;
450 const existing = projects.find((project) => project.cwd === cwd);
451 if (existing) {
452 focusMostRecentThreadForProject(existing.id);
453 } else {
454 const projectId = newProjectId();
455 const createdAt = new Date().toISOString();
456 const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd;
457 const projectCreated = await api.orchestration
458 .dispatchCommand({
459 type: "project.create",
460 commandId: newCommandId(),
461 projectId,
462 title,
463 workspaceRoot: cwd,
464 defaultModel: DEFAULT_MODEL,
465 createdAt,
466 })
467 .then(() => true)
468 .catch(() => false);
469 if (projectCreated) {
470 await handleNewThread(projectId).catch(() => undefined);
471 }
472 }
473 } finally {
449 const finishAddingProject = () => {
474450 setIsAddingProject(false);
475451 setNewCwd("");
476452 setAddingProject(false);
453 };
454
455 const existing = projects.find((project) => project.cwd === cwd);
456 if (existing) {
457 focusMostRecentThreadForProject(existing.id);
458 finishAddingProject();
459 return;
460 }
461
462 const projectId = newProjectId();
463 const createdAt = new Date().toISOString();
464 const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd;
465 const projectCreated = await api.orchestration
466 .dispatchCommand({
467 type: "project.create",
468 commandId: newCommandId(),
469 projectId,
470 title,
471 workspaceRoot: cwd,
472 defaultModel: DEFAULT_MODEL,
473 createdAt,
474 })
475 .then(() => true)
476 .catch(() => false);
477 if (projectCreated) {
478 await handleNewThread(projectId).catch(() => undefined);
477479 }
480 finishAddingProject();
478481 },
479482 [focusMostRecentThreadForProject, handleNewThread, isAddingProject, projects],
480483 );
apps/web/src/components/ui/combobox.tsx+2-3
......@@ -20,10 +20,9 @@ function Combobox<Value, Multiple extends boolean | undefined = false>(
2020 props: ComboboxPrimitive.Root.Props<Value, Multiple>,
2121) {
2222 const chipsRef = React.useRef<Element | null>(null);
23 const value = React.useMemo(() => ({ chipsRef, multiple: !!props.multiple }), [props.multiple]);
2324 return (
24 <ComboboxContext.Provider
25 value={React.useMemo(() => ({ chipsRef, multiple: !!props.multiple }), [props.multiple])}
26 >
25 <ComboboxContext.Provider value={value}>
2726 <ComboboxPrimitive.Root {...props} />
2827 </ComboboxContext.Provider>
2928 );
apps/web/src/components/ui/toggle-group.tsx+2-5
......@@ -22,6 +22,7 @@ function ToggleGroup({
2222 children,
2323 ...props
2424}: ToggleGroupPrimitive.Props & VariantProps<typeof toggleVariants>) {
25 const value = React.useMemo(() => ({ size, variant }), [size, variant]);
2526 return (
2627 <ToggleGroupPrimitive
2728 className={cn(
......@@ -42,11 +43,7 @@ function ToggleGroup({
4243 orientation={orientation}
4344 {...props}
4445 >
45 <ToggleGroupContext.Provider
46 value={React.useMemo(() => ({ size, variant }), [size, variant])}
47 >
48 {children}
49 </ToggleGroupContext.Provider>
46 <ToggleGroupContext.Provider value={value}>{children}</ToggleGroupContext.Provider>
5047 </ToggleGroupPrimitive>
5148 );
5249}
apps/web/src/routes/_chat.$threadId.tsx+2-2
......@@ -185,7 +185,7 @@ function ChatThreadRouteView() {
185185 return (
186186 <>
187187 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
188 <ChatView threadId={threadId} />
188 <ChatView key={threadId} threadId={threadId} />
189189 </SidebarInset>
190190 <DiffPanelInlineSidebar diffOpen={diffOpen} onCloseDiff={closeDiff} onOpenDiff={openDiff} />
191191 </>
......@@ -195,7 +195,7 @@ function ChatThreadRouteView() {
195195 return (
196196 <>
197197 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
198 <ChatView threadId={threadId} />
198 <ChatView key={threadId} threadId={threadId} />
199199 </SidebarInset>
200200 <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}>
201201 <Suspense fallback={<DiffLoadingFallback inline={false} />}>