| ... | @@ -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) { |
| 558 | | 556 | |
| 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 | draftThread | 562 | 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); |
| 586 | | 584 | |
| 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; |
| 660 | | 639 | |
| ... | @@ -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 === 0 | 679 | 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 | } |
| 784 | | 761 | |
| 785 | return byUserMessageId; | 762 | return byUserMessageId; |
| 786 | }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); | 763 | })(); |
| 787 | | 764 | |
| 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) { |
| 793 | | 770 | |
| 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 | }; |
| 1112 | | 1095 | |
| 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]); |
| 1371 | | 1345 | |
| 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]); |
| 1403 | | 1359 | |
| 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]); |
| 1443 | | 1396 | |
| 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 = fallbackPersistedAttachments | 1440 | 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]); |
| 1572 | | 1510 | |
| 1573 | const activeWorktreePath = activeThread?.worktreePath; | 1511 | const activeWorktreePath = activeThread?.worktreePath; |
| 1574 | | 1512 | 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]); | | |
| 1587 | | 1517 | |
| 1588 | // Auto-resize textarea | 1518 | // 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); |
| 1610 | | 1540 | |
| 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 | } |
| 1950 | | 1881 | |
| 1951 | const titleSeed = | 1882 | let firstComposerImageName: string | null = null; |
| 1952 | trimmed || | 1883 | if (composerImagesSnapshot.length > 0) { |
| 1953 | (composerImagesSnapshot.length > 0 | 1884 | 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 | } |
| 1957 | | 1905 | |
| 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 | } |
| 1972 | | 1920 | |
| 1973 | const setupScript = baseBranchForWorktree | 1921 | 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 | } |
| 1984 | | 1946 | |
| 1985 | // Auto-title from first message | 1947 | // 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.orchestration | 1985 | 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 | ); | | |
| 2273 | | 2229 | |
| 2274 | // Empty state: no active thread | 2230 | // Empty state: no active thread |
| 2275 | if (!activeThread) { | 2231 | if (!activeThread) { |