authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-26 18:08:32-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 20:25:27-08:00
log58692df0d2b33cba8fe63df53f4bd04b311071b5
treea832828aedd1b8c4d88fcbeca9e6a1632281e316
parent68585d4638b594cc79423ad74dff0ea0608fcb46

Split send state into explicit worktree and turn phases

- Replace boolean `isSending` with `sendPhase` state - Show "Preparing worktree..." status before first-turn dispatch - Keep composer editable while preventing duplicate sends and restore input on pre-send failures

1 files changed, 67 insertions(+), 51 deletions(-)

apps/web/src/components/ChatView.tsx+67-51
...@@ -188,6 +188,8 @@ type ComposerCommandItem =...@@ -188,6 +188,8 @@ type ComposerCommandItem =
188 description: string;188 description: string;
189 };189 };
190190
191type SendPhase = "idle" | "preparing-worktree" | "sending-turn";
192
191function readFileAsDataUrl(file: File): Promise<string> {193function readFileAsDataUrl(file: File): Promise<string> {
192 return new Promise((resolve, reject) => {194 return new Promise((resolve, reject) => {
193 const reader = new FileReader();195 const reader = new FileReader();
...@@ -342,7 +344,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -342,7 +344,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
342 const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);344 const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
343 const [isDragOverComposer, setIsDragOverComposer] = useState(false);345 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
344 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);346 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
345 const [isSending, setIsSending] = useState(false);347 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
346 const [isConnecting, setIsConnecting] = useState(false);348 const [isConnecting, setIsConnecting] = useState(false);
347 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);349 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
348 const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);350 const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);
...@@ -397,7 +399,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -397,7 +399,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
397 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,399 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
398 );400 );
399 const phase = derivePhase(activeThread?.session ?? null);401 const phase = derivePhase(activeThread?.session ?? null);
400 const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint;402 const isSendBusy = sendPhase !== "idle";
403 const isPreparingWorktree = sendPhase === "preparing-worktree";
404 const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
401 const nowIso = new Date(nowTick).toISOString();405 const nowIso = new Date(nowTick).toISOString();
402 const threadActivities = activeThread?.activities ?? [];406 const threadActivities = activeThread?.activities ?? [];
403 const workLogEntries = useMemo(407 const workLogEntries = useMemo(
...@@ -1026,7 +1030,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1026,7 +1030,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1026 });1030 });
1027 setPrompt("");1031 setPrompt("");
1028 promptRef.current = "";1032 promptRef.current = "";
1029 setIsSending(false);1033 setSendPhase("idle");
1030 setComposerCursor(0);1034 setComposerCursor(0);
1031 setComposerHighlightedItemId(null);1035 setComposerHighlightedItemId(null);
1032 dragDepthRef.current = 0;1036 dragDepthRef.current = 0;
...@@ -1315,7 +1319,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1315,7 +1319,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1315 const api = readNativeApi();1319 const api = readNativeApi();
1316 if (!api || !activeThread || isRevertingCheckpoint) return;1320 if (!api || !activeThread || isRevertingCheckpoint) return;
13171321
1318 if (phase === "running" || isSending || isConnecting) {1322 if (phase === "running" || isSendBusy || isConnecting) {
1319 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");1323 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
1320 return;1324 return;
1321 }1325 }
...@@ -1349,36 +1353,46 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1349,36 +1353,46 @@ export default function ChatView({ threadId }: ChatViewProps) {
1349 setIsRevertingCheckpoint(false);1353 setIsRevertingCheckpoint(false);
1350 }1354 }
1351 },1355 },
1352 [activeThread, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError],1356 [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
1353 );1357 );
13541358
1355 const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => {1359 const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => {
1356 e.preventDefault();1360 e.preventDefault();
1357 const api = readNativeApi();1361 const api = readNativeApi();
1358 if (!api || !activeThread || isSending || isConnecting) return;1362 if (!api || !activeThread || isSendBusy || isConnecting) return;
1359 const trimmed = prompt.trim();1363 const trimmed = prompt.trim();
1360 if (!trimmed && composerImages.length === 0) return;1364 if (!trimmed && composerImages.length === 0) return;
1361 if (!activeProject) return;1365 if (!activeProject) return;
1366 const threadIdForSend = activeThread.id;
1367 const isFirstMessage = activeThread.messages.length === 0;
1368 const baseBranchForWorktree =
1369 isFirstMessage && envMode === "worktree" && !activeThread.worktreePath
1370 ? activeThread.branch
1371 : null;
1362 const composerImagesSnapshot = [...composerImages];1372 const composerImagesSnapshot = [...composerImages];
13631373
1364 // On first message: lock in branch + create worktree if needed.1374 setThreadError(threadIdForSend, null);
1365 if (1375 promptRef.current = "";
1366 activeThread.messages.length === 0 &&1376 setPrompt("");
1367 activeThread.branch &&1377 setComposerImages([]);
1368 envMode === "worktree" &&1378 setComposerCursor(0);
1369 !activeThread.worktreePath1379 setComposerHighlightedItemId(null);
1370 ) {1380
1371 try {1381 let attemptedTurnStart = false;
1382 try {
1383 // On first message: lock in branch + create worktree if needed.
1384 if (baseBranchForWorktree) {
1385 setSendPhase("preparing-worktree");
1372 const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`;1386 const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`;
1373 const result = await createWorktreeMutation.mutateAsync({1387 const result = await createWorktreeMutation.mutateAsync({
1374 cwd: activeProject.cwd,1388 cwd: activeProject.cwd,
1375 branch: activeThread.branch,1389 branch: baseBranchForWorktree,
1376 newBranch,1390 newBranch,
1377 });1391 });
1378 await api.orchestration.dispatchCommand({1392 await api.orchestration.dispatchCommand({
1379 type: "thread.meta.update",1393 type: "thread.meta.update",
1380 commandId: newCommandId(),1394 commandId: newCommandId(),
1381 threadId: activeThread.id,1395 threadId: threadIdForSend,
1382 branch: result.worktree.branch,1396 branch: result.worktree.branch,
1383 worktreePath: result.worktree.path,1397 worktreePath: result.worktree.path,
1384 });1398 });
...@@ -1390,43 +1404,25 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1390,43 +1404,25 @@ export default function ChatView({ threadId }: ChatViewProps) {
1390 rememberAsLastInvoked: false,1404 rememberAsLastInvoked: false,
1391 });1405 });
1392 }1406 }
1393 } catch (err) {
1394 dispatch({
1395 type: "SET_ERROR",
1396 threadId: activeThread.id,
1397 error: err instanceof Error ? err.message : "Failed to create worktree",
1398 });
1399 return;
1400 }1407 }
1401 }
14021408
1403 // Auto-title from first message1409 // Auto-title from first message
1404 if (activeThread.messages.length === 0) {1410 if (isFirstMessage) {
1405 const titleSeed =1411 const titleSeed =
1406 trimmed ||1412 trimmed ||
1407 (composerImagesSnapshot.length > 01413 (composerImagesSnapshot.length > 0
1408 ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`1414 ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
1409 : "New thread");1415 : "New thread");
1410 const title = truncateTitle(titleSeed);1416 const title = truncateTitle(titleSeed);
1411 if (api) {
1412 await api.orchestration.dispatchCommand({1417 await api.orchestration.dispatchCommand({
1413 type: "thread.meta.update",1418 type: "thread.meta.update",
1414 commandId: newCommandId(),1419 commandId: newCommandId(),
1415 threadId: activeThread.id,1420 threadId: threadIdForSend,
1416 title,1421 title,
1417 });1422 });
1418 }1423 }
1419 }
14201424
1421 setThreadError(activeThread.id, null);1425 setSendPhase("sending-turn");
1422 promptRef.current = "";
1423 setPrompt("");
1424 setComposerImages([]);
1425 setComposerCursor(0);
1426 setComposerHighlightedItemId(null);
1427
1428 setIsSending(true);
1429 try {
1430 const turnAttachments = await Promise.all(1426 const turnAttachments = await Promise.all(
1431 composerImagesSnapshot.map(1427 composerImagesSnapshot.map(
1432 async (1428 async (
...@@ -1446,10 +1442,11 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1446,10 +1442,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
1446 }),1442 }),
1447 ),1443 ),
1448 );1444 );
1445 attemptedTurnStart = true;
1449 await api.orchestration.dispatchCommand({1446 await api.orchestration.dispatchCommand({
1450 type: "thread.turn.start",1447 type: "thread.turn.start",
1451 commandId: newCommandId(),1448 commandId: newCommandId(),
1452 threadId: activeThread.id,1449 threadId: threadIdForSend,
1453 message: {1450 message: {
1454 messageId: newMessageId(),1451 messageId: newMessageId(),
1455 role: "user",1452 role: "user",
...@@ -1461,12 +1458,22 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1461,12 +1458,22 @@ export default function ChatView({ threadId }: ChatViewProps) {
1461 createdAt: new Date().toISOString(),1458 createdAt: new Date().toISOString(),
1462 });1459 });
1463 } catch (err) {1460 } catch (err) {
1461 if (
1462 !attemptedTurnStart &&
1463 promptRef.current.length === 0 &&
1464 composerImagesRef.current.length === 0
1465 ) {
1466 promptRef.current = trimmed;
1467 setPrompt(trimmed);
1468 setComposerImages(composerImagesSnapshot);
1469 setComposerCursor(trimmed.length);
1470 }
1464 setThreadError(1471 setThreadError(
1465 activeThread.id,1472 threadIdForSend,
1466 err instanceof Error ? err.message : "Failed to send message.",1473 err instanceof Error ? err.message : "Failed to send message.",
1467 );1474 );
1468 } finally {1475 } finally {
1469 setIsSending(false);1476 setSendPhase("idle");
1470 }1477 }
1471 };1478 };
14721479
...@@ -1851,7 +1858,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1851,7 +1858,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1851 ? "Ask for follow-up changes or attach images"1858 ? "Ask for follow-up changes or attach images"
1852 : "Ask anything, @tag files/folders, or use /model"1859 : "Ask anything, @tag files/folders, or use /model"
1853 }1860 }
1854 disabled={isSending || isConnecting}1861 disabled={isConnecting}
1855 />1862 />
1856 </div>1863 </div>
18571864
...@@ -1897,6 +1904,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1897,6 +1904,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
18971904
1898 {/* Right side: send / stop button */}1905 {/* Right side: send / stop button */}
1899 <div className="flex shrink-0 items-center gap-2">1906 <div className="flex shrink-0 items-center gap-2">
1907 {isPreparingWorktree ? (
1908 <span className="text-muted-foreground/70 text-xs">Preparing worktree...</span>
1909 ) : null}
1900 {phase === "running" ? (1910 {phase === "running" ? (
1901 <button1911 <button
1902 type="button"1912 type="button"
...@@ -1919,13 +1929,19 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1919,13 +1929,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
1919 type="submit"1929 type="submit"
1920 className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8"1930 className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8"
1921 disabled={1931 disabled={
1922 isSending || isConnecting || (!prompt.trim() && composerImages.length === 0)1932 isSendBusy || isConnecting || (!prompt.trim() && composerImages.length === 0)
1923 }1933 }
1924 aria-label={1934 aria-label={
1925 isConnecting ? "Connecting" : isSending ? "Sending" : "Send message"1935 isConnecting
1936 ? "Connecting"
1937 : isPreparingWorktree
1938 ? "Preparing worktree"
1939 : isSendBusy
1940 ? "Sending"
1941 : "Send message"
1926 }1942 }
1927 >1943 >
1928 {isConnecting || isSending ? (1944 {isConnecting || isSendBusy ? (
1929 <svg1945 <svg
1930 width="14"1946 width="14"
1931 height="14"1947 height="14"