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) {...@@ -485,7 +485,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
485 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");485 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
486 const [isConnecting, _setIsConnecting] = useState(false);486 const [isConnecting, _setIsConnecting] = useState(false);
487 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);487 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
488 const [envMode, setEnvMode] = useState<DraftThreadEnvMode>("local");
489 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);488 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
490 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);489 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
491 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});490 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
...@@ -505,7 +504,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -505,7 +504,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
505 const textareaRef = useRef<HTMLTextAreaElement>(null);504 const textareaRef = useRef<HTMLTextAreaElement>(null);
506 const composerFormRef = useRef<HTMLFormElement>(null);505 const composerFormRef = useRef<HTMLFormElement>(null);
507 const composerFormHeightRef = useRef(0);506 const composerFormHeightRef = useRef(0);
508 const composerCommandInputRef = useRef<HTMLInputElement>(null);
509 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);507 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
510 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});508 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
511 const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef<Record<string, number>>({});509 const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef<Record<string, number>>({});
...@@ -558,7 +556,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -558,7 +556,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
558556
559 const serverThread = threads.find((t) => t.id === threadId);557 const serverThread = threads.find((t) => t.id === threadId);
560 const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId);558 const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId);
561 const localDraftError = localDraftErrorsByThreadId[threadId] ?? null;559 const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null);
562 const localDraftThread = useMemo(560 const localDraftThread = useMemo(
563 () =>561 () =>
564 draftThread562 draftThread
...@@ -584,19 +582,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -584,19 +582,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
584 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);582 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
585 const activeProject = projects.find((p) => p.id === activeThread?.projectId);583 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
600 useEffect(() => {585 useEffect(() => {
601 if (!activeThread?.id) return;586 if (!activeThread?.id) return;
602 if (!latestTurnSettled) return;587 if (!latestTurnSettled) return;
...@@ -618,27 +603,16 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -618,27 +603,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
618 const baseThreadModel = resolveModelSlug(activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL);603 const baseThreadModel = resolveModelSlug(activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL);
619 const selectedModel = resolveModelSlug(composerDraft.model ?? baseThreadModel);604 const selectedModel = resolveModelSlug(composerDraft.model ?? baseThreadModel);
620 const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING;605 const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING;
621 const modelOptions = useMemo(606 const modelOptions = getAppModelOptions(settings.customCodexModels, selectedModel);
622 () => getAppModelOptions(settings.customCodexModels, selectedModel),
623 [selectedModel, settings.customCodexModels],
624 );
625 const phase = derivePhase(activeThread?.session ?? null);607 const phase = derivePhase(activeThread?.session ?? null);
626 const isSendBusy = sendPhase !== "idle";608 const isSendBusy = sendPhase !== "idle";
627 const isPreparingWorktree = sendPhase === "preparing-worktree";609 const isPreparingWorktree = sendPhase === "preparing-worktree";
628 const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;610 const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
629 const nowIso = new Date(nowTick).toISOString();611 const nowIso = new Date(nowTick).toISOString();
630 const threadActivities = activeThread?.activities ?? [];612 const threadActivities = activeThread?.activities ?? [];
631 const workLogEntries = useMemo(613 const workLogEntries = deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined);
632 () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined),614 const latestTurnHasToolActivity = hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId);
633 [activeLatestTurn?.turnId, threadActivities],615 const pendingApprovals = derivePendingApprovals(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 );
642 useEffect(() => {616 useEffect(() => {
643 attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;617 attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;
644 }, [attachmentPreviewHandoffByMessageId]);618 }, [attachmentPreviewHandoffByMessageId]);
...@@ -655,6 +629,11 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -655,6 +629,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
655 attachmentPreviewHandoffByMessageIdRef.current = {};629 attachmentPreviewHandoffByMessageIdRef.current = {};
656 setAttachmentPreviewHandoffByMessageId({});630 setAttachmentPreviewHandoffByMessageId({});
657 }, []);631 }, []);
632 useEffect(() => {
633 return () => {
634 clearAttachmentPreviewHandoffs();
635 };
636 }, [clearAttachmentPreviewHandoffs]);
658 const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => {637 const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => {
659 if (previewUrls.length === 0) return;638 if (previewUrls.length === 0) return;
660639
...@@ -686,14 +665,15 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -686,14 +665,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
686 }665 }
687 setAttachmentPreviewHandoffByMessageId((existing) => {666 setAttachmentPreviewHandoffByMessageId((existing) => {
688 if (!(messageId in existing)) return existing;667 if (!(messageId in existing)) return existing;
689 const { [messageId]: _removed, ...rest } = existing;668 const next = { ...existing };
690 attachmentPreviewHandoffByMessageIdRef.current = rest;669 delete next[messageId];
691 return rest;670 attachmentPreviewHandoffByMessageIdRef.current = next;
671 return next;
692 });672 });
693 delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId];673 delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId];
694 }, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS);674 }, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS);
695 }, []);675 }, []);
696 const timelineMessages = useMemo(() => {676 const timelineMessages = (() => {
697 const serverMessages = activeThread?.messages ?? [];677 const serverMessages = activeThread?.messages ?? [];
698 const serverMessagesWithPreviewHandoff =678 const serverMessagesWithPreviewHandoff =
699 Object.keys(attachmentPreviewHandoffByMessageId).length === 0679 Object.keys(attachmentPreviewHandoffByMessageId).length === 0
...@@ -737,11 +717,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -737,11 +717,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
737 return serverMessagesWithPreviewHandoff;717 return serverMessagesWithPreviewHandoff;
738 }718 }
739 return [...serverMessagesWithPreviewHandoff, ...pendingMessages];719 return [...serverMessagesWithPreviewHandoff, ...pendingMessages];
740 }, [activeThread?.messages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]);720 })();
741 const timelineEntries = useMemo(721 const timelineEntries = deriveTimelineEntries(timelineMessages, workLogEntries);
742 () => deriveTimelineEntries(timelineMessages, workLogEntries),
743 [timelineMessages, workLogEntries],
744 );
745 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =722 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
746 useTurnDiffSummaries(activeThread);723 useTurnDiffSummaries(activeThread);
747 const turnDiffSummaryByAssistantMessageId = useMemo(() => {724 const turnDiffSummaryByAssistantMessageId = useMemo(() => {
...@@ -752,7 +729,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -752,7 +729,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
752 }729 }
753 return byMessageId;730 return byMessageId;
754 }, [turnDiffSummaries]);731 }, [turnDiffSummaries]);
755 const revertTurnCountByUserMessageId = useMemo(() => {732 const revertTurnCountByUserMessageId = (() => {
756 const byUserMessageId = new Map<MessageId, number>();733 const byUserMessageId = new Map<MessageId, number>();
757 for (let index = 0; index < timelineEntries.length; index += 1) {734 for (let index = 0; index < timelineEntries.length; index += 1) {
758 const entry = timelineEntries[index];735 const entry = timelineEntries[index];
...@@ -783,9 +760,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -783,9 +760,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
783 }760 }
784761
785 return byUserMessageId;762 return byUserMessageId;
786 }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);763 })();
787764
788 const completionSummary = useMemo(() => {765 const completionSummary = (() => {
789 if (!latestTurnSettled) return null;766 if (!latestTurnSettled) return null;
790 if (!activeLatestTurn?.startedAt) return null;767 if (!activeLatestTurn?.startedAt) return null;
791 if (!activeLatestTurn.completedAt) return null;768 if (!activeLatestTurn.completedAt) return null;
...@@ -793,13 +770,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -793,13 +770,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
793770
794 const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt);771 const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt);
795 return elapsed ? `Worked for ${elapsed}` : null;772 return elapsed ? `Worked for ${elapsed}` : null;
796 }, [773 })();
797 activeLatestTurn?.completedAt,774 const completionDividerBeforeEntryId = (() => {
798 activeLatestTurn?.startedAt,
799 latestTurnHasToolActivity,
800 latestTurnSettled,
801 ]);
802 const completionDividerBeforeEntryId = useMemo(() => {
803 if (!latestTurnSettled) return null;775 if (!latestTurnSettled) return null;
804 if (!activeLatestTurn?.startedAt) return null;776 if (!activeLatestTurn?.startedAt) return null;
805 if (!activeLatestTurn.completedAt) return null;777 if (!activeLatestTurn.completedAt) return null;
...@@ -823,13 +795,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -823,13 +795,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
823 }795 }
824 }796 }
825 return inRangeMatch ?? fallbackMatch;797 return inRangeMatch ?? fallbackMatch;
826 }, [798 })();
827 activeLatestTurn?.completedAt,
828 activeLatestTurn?.startedAt,
829 completionSummary,
830 latestTurnSettled,
831 timelineEntries,
832 ]);
833 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;799 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
834 const composerTrigger = useMemo(800 const composerTrigger = useMemo(
835 () => detectComposerTrigger(prompt, composerCursor),801 () => detectComposerTrigger(prompt, composerCursor),
...@@ -918,15 +884,17 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -918,15 +884,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
918 [nonPersistedComposerImageIds],884 [nonPersistedComposerImageIds],
919 );885 );
920 const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;886 const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;
887 const activeProjectCwd = activeProject?.cwd ?? null;
888 const activeThreadWorktreePath = activeThread?.worktreePath ?? null;
921 const threadTerminalRuntimeEnv = useMemo(() => {889 const threadTerminalRuntimeEnv = useMemo(() => {
922 if (!activeProject?.cwd) return {};890 if (!activeProjectCwd) return {};
923 return projectScriptRuntimeEnv({891 return projectScriptRuntimeEnv({
924 project: {892 project: {
925 cwd: activeProject.cwd,893 cwd: activeProjectCwd,
926 },894 },
927 worktreePath: activeThread?.worktreePath ?? null,895 worktreePath: activeThreadWorktreePath,
928 });896 });
929 }, [activeProject?.cwd, activeThread?.worktreePath]);897 }, [activeProjectCwd, activeThreadWorktreePath]);
930 // Default true while loading to avoid toolbar flicker.898 // Default true while loading to avoid toolbar flicker.
931 const isGitRepo = branchesQuery.data?.isRepo ?? true;899 const isGitRepo = branchesQuery.data?.isRepo ?? true;
932 const splitTerminalShortcutLabel = useMemo(900 const splitTerminalShortcutLabel = useMemo(
...@@ -1109,20 +1077,24 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1109,20 +1077,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
1109 worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null,1077 worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null,
1110 ...(options?.env ? { extraEnv: options.env } : {}),1078 ...(options?.env ? { extraEnv: options.env } : {}),
1111 });1079 });
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
1113 try {1096 try {
1114 await api.terminal.open({1097 await api.terminal.open(openTerminalInput);
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 });
1126 await api.terminal.write({1098 await api.terminal.write({
1127 threadId: activeThreadId,1099 threadId: activeThreadId,
1128 terminalId: targetTerminalId,1100 terminalId: targetTerminalId,
...@@ -1140,12 +1112,14 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1140,12 +1112,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
1140 activeThread,1112 activeThread,
1141 activeThreadId,1113 activeThreadId,
1142 gitCwd,1114 gitCwd,
1143 isLocalDraftThread,
1144 isServerThread,1115 isServerThread,
1145 setTerminalOpen,1116 setTerminalOpen,
1146 setThreadError,1117 setThreadError,
1147 storeNewTerminal,1118 storeNewTerminal,
1148 storeSetActiveTerminal,1119 storeSetActiveTerminal,
1120 terminalState.activeTerminalId,
1121 terminalState.runningTerminalIds,
1122 terminalState.terminalIds,
1149 ],1123 ],
1150 );1124 );
1151 const persistProjectScripts = useCallback(1125 const persistProjectScripts = useCallback(
...@@ -1369,24 +1343,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1369,24 +1343,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
1369 scheduleStickToBottom();1343 scheduleStickToBottom();
1370 }, [phase, scheduleStickToBottom, timelineEntries]);1344 }, [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
1390 useEffect(() => {1346 useEffect(() => {
1391 if (!activeThread?.id || terminalState.terminalOpen) return;1347 if (!activeThread?.id || terminalState.terminalOpen) return;
1392 const frame = window.requestAnimationFrame(() => {1348 const frame = window.requestAnimationFrame(() => {
...@@ -1402,16 +1358,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1402,16 +1358,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1402 }, [composerImages]);1358 }, [composerImages]);
14031359
1404 useEffect(() => {1360 useEffect(() => {
1405 if (!activeThread?.id) {1361 if (!activeThread?.id) return;
1406 clearAttachmentPreviewHandoffs();
1407 setOptimisticUserMessages((existing) => {
1408 for (const message of existing) {
1409 revokeUserMessagePreviewUrls(message);
1410 }
1411 return [];
1412 });
1413 return;
1414 }
1415 if (activeThread.messages.length === 0) {1362 if (activeThread.messages.length === 0) {
1416 return;1363 return;
1417 }1364 }
...@@ -1420,7 +1367,11 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1420,7 +1367,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
1420 if (removedMessages.length === 0) {1367 if (removedMessages.length === 0) {
1421 return;1368 return;
1422 }1369 }
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);
1424 for (const removedMessage of removedMessages) {1375 for (const removedMessage of removedMessages) {
1425 const previewUrls = collectUserMessageBlobPreviewUrls(removedMessage);1376 const previewUrls = collectUserMessageBlobPreviewUrls(removedMessage);
1426 if (previewUrls.length > 0) {1377 if (previewUrls.length > 0) {
...@@ -1429,10 +1380,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1429,10 +1380,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
1429 }1380 }
1430 revokeUserMessagePreviewUrls(removedMessage);1381 revokeUserMessagePreviewUrls(removedMessage);
1431 }1382 }
1383 return () => {
1384 window.clearTimeout(timer);
1385 };
1432 }, [1386 }, [
1433 activeThread?.id,1387 activeThread?.id,
1434 activeThread?.messages,1388 activeThread?.messages,
1435 clearAttachmentPreviewHandoffs,
1436 handoffAttachmentPreviews,1389 handoffAttachmentPreviews,
1437 optimisticUserMessages,1390 optimisticUserMessages,
1438 ]);1391 ]);
...@@ -1441,21 +1394,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1441,21 +1394,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
1441 promptRef.current = prompt;1394 promptRef.current = prompt;
1442 }, [prompt]);1395 }, [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
1459 useEffect(() => {1397 useEffect(() => {
1460 let cancelled = false;1398 let cancelled = false;
1461 void (async () => {1399 void (async () => {
...@@ -1463,9 +1401,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1463,9 +1401,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
1463 clearComposerDraftPersistedAttachments(threadId);1401 clearComposerDraftPersistedAttachments(threadId);
1464 return;1402 return;
1465 }1403 }
1404 const getPersistedAttachmentsForThread = () =>
1405 useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? [];
1466 try {1406 try {
1467 const currentPersistedAttachments =1407 const currentPersistedAttachments = getPersistedAttachmentsForThread();
1468 useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? [];
1469 const existingPersistedById = new Map(1408 const existingPersistedById = new Map(
1470 currentPersistedAttachments.map((attachment) => [attachment.id, attachment]),1409 currentPersistedAttachments.map((attachment) => [attachment.id, attachment]),
1471 );1410 );
...@@ -1497,8 +1436,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1497,8 +1436,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1497 syncComposerDraftPersistedAttachments(threadId, serialized);1436 syncComposerDraftPersistedAttachments(threadId, serialized);
1498 } catch {1437 } catch {
1499 const currentImageIds = new Set(composerImages.map((image) => image.id));1438 const currentImageIds = new Set(composerImages.map((image) => image.id));
1500 const fallbackPersistedAttachments =1439 const fallbackPersistedAttachments = getPersistedAttachmentsForThread();
1501 useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? [];
1502 const fallbackPersistedIds = fallbackPersistedAttachments1440 const fallbackPersistedIds = fallbackPersistedAttachments
1503 .map((attachment) => attachment.id)1441 .map((attachment) => attachment.id)
1504 .filter((id) => currentImageIds.has(id));1442 .filter((id) => currentImageIds.has(id));
...@@ -1571,19 +1509,11 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1571,19 +1509,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
1571 }, [closeExpandedImage, expandedImage, navigateExpandedImage]);1509 }, [closeExpandedImage, expandedImage, navigateExpandedImage]);
15721510
1573 const activeWorktreePath = activeThread?.worktreePath;1511 const activeWorktreePath = activeThread?.worktreePath;
15741512 const envMode: DraftThreadEnvMode = activeWorktreePath
1575 useEffect(() => {1513 ? "worktree"
1576 if (!activeThread?.id) return;1514 : isLocalDraftThread
1577 if (activeWorktreePath) {1515 ? (draftThread?.envMode ?? "local")
1578 setEnvMode("worktree");1516 : "local";
1579 return;
1580 }
1581 if (isLocalDraftThread) {
1582 setEnvMode(draftThread?.envMode ?? "local");
1583 return;
1584 }
1585 setEnvMode("local");
1586 }, [activeThread?.id, activeWorktreePath, draftThread?.envMode, isLocalDraftThread]);
15871517
1588 // Auto-resize textarea1518 // Auto-resize textarea
1589 useEffect(() => {1519 useEffect(() => {
...@@ -1609,7 +1539,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1609,7 +1539,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
1609 const current = Boolean(terminalState.terminalOpen);1539 const current = Boolean(terminalState.terminalOpen);
16101540
1611 if (!previous && current) {1541 if (!previous && current) {
1612 setTerminalFocusRequestId((value) => value + 1);1542 terminalOpenByThreadRef.current[activeThreadId] = current;
1543 return;
1613 } else if (previous && !current) {1544 } else if (previous && !current) {
1614 terminalOpenByThreadRef.current[activeThreadId] = current;1545 terminalOpenByThreadRef.current[activeThreadId] = current;
1615 const frame = window.requestAnimationFrame(() => {1546 const frame = window.requestAnimationFrame(() => {
...@@ -1922,7 +1853,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1922,7 +1853,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1922 let turnStartSucceeded = false;1853 let turnStartSucceeded = false;
1923 let nextThreadBranch = activeThread.branch;1854 let nextThreadBranch = activeThread.branch;
1924 let nextThreadWorktreePath = activeThread.worktreePath;1855 let nextThreadWorktreePath = activeThread.worktreePath;
1925 try {1856 await (async () => {
1926 // On first message: lock in branch + create worktree if needed.1857 // On first message: lock in branch + create worktree if needed.
1927 if (baseBranchForWorktree) {1858 if (baseBranchForWorktree) {
1928 setSendPhase("preparing-worktree");1859 setSendPhase("preparing-worktree");
...@@ -1948,12 +1879,29 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1948,12 +1879,29 @@ export default function ChatView({ threadId }: ChatViewProps) {
1948 }1879 }
1949 }1880 }
19501881
1951 const titleSeed =1882 let firstComposerImageName: string | null = null;
1952 trimmed ||1883 if (composerImagesSnapshot.length > 0) {
1953 (composerImagesSnapshot.length > 01884 const firstComposerImage = composerImagesSnapshot[0];
1954 ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`1885 if (firstComposerImage) {
1955 : "New thread");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 }
1956 const title = truncateTitle(titleSeed);1897 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
1958 if (isLocalDraftThread) {1906 if (isLocalDraftThread) {
1959 await api.orchestration.dispatchCommand({1907 await api.orchestration.dispatchCommand({
...@@ -1962,7 +1910,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1962,7 +1910,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1962 threadId: threadIdForSend,1910 threadId: threadIdForSend,
1963 projectId: activeProject.id,1911 projectId: activeProject.id,
1964 title,1912 title,
1965 model: selectedModel ?? activeProject.model ?? DEFAULT_MODEL,1913 model: threadCreateModel,
1966 branch: nextThreadBranch,1914 branch: nextThreadBranch,
1967 worktreePath: nextThreadWorktreePath,1915 worktreePath: nextThreadWorktreePath,
1968 createdAt: activeThread.createdAt,1916 createdAt: activeThread.createdAt,
...@@ -1970,16 +1918,30 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1970,16 +1918,30 @@ export default function ChatView({ threadId }: ChatViewProps) {
1970 createdServerThreadForLocalDraft = true;1918 createdServerThreadForLocalDraft = true;
1971 }1919 }
19721920
1973 const setupScript = baseBranchForWorktree1921 let setupScript: ProjectScript | null = null;
1974 ? setupProjectScript(activeProject.scripts)1922 if (baseBranchForWorktree) {
1975 : null;1923 setupScript = setupProjectScript(activeProject.scripts);
1976 if (setupScript && (isServerThread || createdServerThreadForLocalDraft)) {1924 }
1977 await runProjectScript(setupScript, {1925 if (setupScript) {
1978 worktreePath: nextThreadWorktreePath,1926 let shouldRunSetupScript = false;
1979 rememberAsLastInvoked: false,1927 if (isServerThread) {
1980 allowLocalDraftThread: createdServerThreadForLocalDraft,1928 shouldRunSetupScript = true;
1981 ...(nextThreadWorktreePath ? { cwd: nextThreadWorktreePath } : {}),1929 } else {
1982 });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 }
1983 }1945 }
19841946
1985 // Auto-title from first message1947 // Auto-title from first message
...@@ -2018,7 +1980,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2018,7 +1980,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2018 if (isFirstMessage) {1980 if (isFirstMessage) {
2019 clearDraftThread(threadIdForSend);1981 clearDraftThread(threadIdForSend);
2020 }1982 }
2021 } catch (err) {1983 })().catch(async (err: unknown) => {
2022 if (createdServerThreadForLocalDraft && !turnStartSucceeded) {1984 if (createdServerThreadForLocalDraft && !turnStartSucceeded) {
2023 await api.orchestration1985 await api.orchestration
2024 .dispatchCommand({1986 .dispatchCommand({
...@@ -2050,7 +2012,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2050,7 +2012,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2050 threadIdForSend,2012 threadIdForSend,
2051 err instanceof Error ? err.message : "Failed to send message.",2013 err instanceof Error ? err.message : "Failed to send message.",
2052 );2014 );
2053 }2015 });
2054 sendInFlightRef.current = false;2016 sendInFlightRef.current = false;
2055 setSendPhase("idle");2017 setSendPhase("idle");
2056 };2018 };
...@@ -2074,23 +2036,21 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2074,23 +2036,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
2074 setRespondingRequestIds((existing) =>2036 setRespondingRequestIds((existing) =>
2075 existing.includes(requestId) ? existing : [...existing, requestId],2037 existing.includes(requestId) ? existing : [...existing, requestId],
2076 );2038 );
2077 try {2039 await api.orchestration
2078 await api.orchestration.dispatchCommand({2040 .dispatchCommand({
2079 type: "thread.approval.respond",2041 type: "thread.approval.respond",
2080 commandId: newCommandId(),2042 commandId: newCommandId(),
2081 threadId: activeThreadId,2043 threadId: activeThreadId,
2082 requestId,2044 requestId,
2083 decision,2045 decision,
2084 createdAt: new Date().toISOString(),2046 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 );
2085 });2053 });
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 }
2094 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));2054 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
2095 },2055 },
2096 [activeThreadId, setStoreThreadError],2056 [activeThreadId, setStoreThreadError],
...@@ -2130,7 +2090,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2130,7 +2090,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
2130 );2090 );
2131 const onEnvModeChange = useCallback(2091 const onEnvModeChange = useCallback(
2132 (mode: DraftThreadEnvMode) => {2092 (mode: DraftThreadEnvMode) => {
2133 setEnvMode(mode);
2134 if (isLocalDraftThread) {2093 if (isLocalDraftThread) {
2135 setDraftThreadContext(threadId, { envMode: mode });2094 setDraftThreadContext(threadId, { envMode: mode });
2136 }2095 }
...@@ -2260,16 +2219,13 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2260,16 +2219,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
2260 },2219 },
2261 [navigate, threadId],2220 [navigate, threadId],
2262 );2221 );
2263 const onRevertUserMessage = useCallback(2222 const onRevertUserMessage = (messageId: MessageId) => {
2264 (messageId: MessageId) => {2223 const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);
2265 const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);2224 if (typeof targetTurnCount !== "number") {
2266 if (typeof targetTurnCount !== "number") {2225 return;
2267 return;2226 }
2268 }2227 void onRevertToTurnCount(targetTurnCount);
2269 void onRevertToTurnCount(targetTurnCount);2228 };
2270 },
2271 [onRevertToTurnCount, revertTurnCountByUserMessageId],
2272 );
22732229
2274 // Empty state: no active thread2230 // Empty state: no active thread
2275 if (!activeThread) {2231 if (!activeThread) {
apps/web/src/components/Sidebar.tsx+29-26
...@@ -443,38 +443,41 @@ export default function Sidebar() {...@@ -443,38 +443,41 @@ export default function Sidebar() {
443 const cwd = rawCwd.trim();443 const cwd = rawCwd.trim();
444 if (!cwd || isAddingProject) return;444 if (!cwd || isAddingProject) return;
445 const api = readNativeApi();445 const api = readNativeApi();
446 if (!api) return;
446447
447 setIsAddingProject(true);448 setIsAddingProject(true);
448 try {449 const finishAddingProject = () => {
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 {
474 setIsAddingProject(false);450 setIsAddingProject(false);
475 setNewCwd("");451 setNewCwd("");
476 setAddingProject(false);452 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);
477 }479 }
480 finishAddingProject();
478 },481 },
479 [focusMostRecentThreadForProject, handleNewThread, isAddingProject, projects],482 [focusMostRecentThreadForProject, handleNewThread, isAddingProject, projects],
480 );483 );
apps/web/src/components/ui/combobox.tsx+2-3
...@@ -20,10 +20,9 @@ function Combobox<Value, Multiple extends boolean | undefined = false>(...@@ -20,10 +20,9 @@ function Combobox<Value, Multiple extends boolean | undefined = false>(
20 props: ComboboxPrimitive.Root.Props<Value, Multiple>,20 props: ComboboxPrimitive.Root.Props<Value, Multiple>,
21) {21) {
22 const chipsRef = React.useRef<Element | null>(null);22 const chipsRef = React.useRef<Element | null>(null);
23 const value = React.useMemo(() => ({ chipsRef, multiple: !!props.multiple }), [props.multiple]);
23 return (24 return (
24 <ComboboxContext.Provider25 <ComboboxContext.Provider value={value}>
25 value={React.useMemo(() => ({ chipsRef, multiple: !!props.multiple }), [props.multiple])}
26 >
27 <ComboboxPrimitive.Root {...props} />26 <ComboboxPrimitive.Root {...props} />
28 </ComboboxContext.Provider>27 </ComboboxContext.Provider>
29 );28 );
apps/web/src/components/ui/toggle-group.tsx+2-5
...@@ -22,6 +22,7 @@ function ToggleGroup({...@@ -22,6 +22,7 @@ function ToggleGroup({
22 children,22 children,
23 ...props23 ...props
24}: ToggleGroupPrimitive.Props & VariantProps<typeof toggleVariants>) {24}: ToggleGroupPrimitive.Props & VariantProps<typeof toggleVariants>) {
25 const value = React.useMemo(() => ({ size, variant }), [size, variant]);
25 return (26 return (
26 <ToggleGroupPrimitive27 <ToggleGroupPrimitive
27 className={cn(28 className={cn(
...@@ -42,11 +43,7 @@ function ToggleGroup({...@@ -42,11 +43,7 @@ function ToggleGroup({
42 orientation={orientation}43 orientation={orientation}
43 {...props}44 {...props}
44 >45 >
45 <ToggleGroupContext.Provider46 <ToggleGroupContext.Provider value={value}>{children}</ToggleGroupContext.Provider>
46 value={React.useMemo(() => ({ size, variant }), [size, variant])}
47 >
48 {children}
49 </ToggleGroupContext.Provider>
50 </ToggleGroupPrimitive>47 </ToggleGroupPrimitive>
51 );48 );
52}49}
apps/web/src/routes/_chat.$threadId.tsx+2-2
...@@ -185,7 +185,7 @@ function ChatThreadRouteView() {...@@ -185,7 +185,7 @@ function ChatThreadRouteView() {
185 return (185 return (
186 <>186 <>
187 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">187 <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} />
189 </SidebarInset>189 </SidebarInset>
190 <DiffPanelInlineSidebar diffOpen={diffOpen} onCloseDiff={closeDiff} onOpenDiff={openDiff} />190 <DiffPanelInlineSidebar diffOpen={diffOpen} onCloseDiff={closeDiff} onOpenDiff={openDiff} />
191 </>191 </>
...@@ -195,7 +195,7 @@ function ChatThreadRouteView() {...@@ -195,7 +195,7 @@ function ChatThreadRouteView() {
195 return (195 return (
196 <>196 <>
197 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">197 <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} />
199 </SidebarInset>199 </SidebarInset>
200 <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}>200 <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}>
201 <Suspense fallback={<DiffLoadingFallback inline={false} />}>201 <Suspense fallback={<DiffLoadingFallback inline={false} />}>