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 =
188188 description: string;
189189 };
190190
191type SendPhase = "idle" | "preparing-worktree" | "sending-turn";
192
191193function readFileAsDataUrl(file: File): Promise<string> {
192194 return new Promise((resolve, reject) => {
193195 const reader = new FileReader();
......@@ -342,7 +344,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
342344 const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
343345 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
344346 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
345 const [isSending, setIsSending] = useState(false);
347 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
346348 const [isConnecting, setIsConnecting] = useState(false);
347349 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
348350 const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);
......@@ -397,7 +399,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
397399 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
398400 );
399401 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;
401405 const nowIso = new Date(nowTick).toISOString();
402406 const threadActivities = activeThread?.activities ?? [];
403407 const workLogEntries = useMemo(
......@@ -1026,7 +1030,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
10261030 });
10271031 setPrompt("");
10281032 promptRef.current = "";
1029 setIsSending(false);
1033 setSendPhase("idle");
10301034 setComposerCursor(0);
10311035 setComposerHighlightedItemId(null);
10321036 dragDepthRef.current = 0;
......@@ -1315,7 +1319,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
13151319 const api = readNativeApi();
13161320 if (!api || !activeThread || isRevertingCheckpoint) return;
13171321
1318 if (phase === "running" || isSending || isConnecting) {
1322 if (phase === "running" || isSendBusy || isConnecting) {
13191323 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
13201324 return;
13211325 }
......@@ -1349,36 +1353,46 @@ export default function ChatView({ threadId }: ChatViewProps) {
13491353 setIsRevertingCheckpoint(false);
13501354 }
13511355 },
1352 [activeThread, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError],
1356 [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
13531357 );
13541358
13551359 const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => {
13561360 e.preventDefault();
13571361 const api = readNativeApi();
1358 if (!api || !activeThread || isSending || isConnecting) return;
1362 if (!api || !activeThread || isSendBusy || isConnecting) return;
13591363 const trimmed = prompt.trim();
13601364 if (!trimmed && composerImages.length === 0) return;
13611365 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;
13621372 const composerImagesSnapshot = [...composerImages];
13631373
1364 // On first message: lock in branch + create worktree if needed.
1365 if (
1366 activeThread.messages.length === 0 &&
1367 activeThread.branch &&
1368 envMode === "worktree" &&
1369 !activeThread.worktreePath
1370 ) {
1371 try {
1374 setThreadError(threadIdForSend, null);
1375 promptRef.current = "";
1376 setPrompt("");
1377 setComposerImages([]);
1378 setComposerCursor(0);
1379 setComposerHighlightedItemId(null);
1380
1381 let attemptedTurnStart = false;
1382 try {
1383 // On first message: lock in branch + create worktree if needed.
1384 if (baseBranchForWorktree) {
1385 setSendPhase("preparing-worktree");
13721386 const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`;
13731387 const result = await createWorktreeMutation.mutateAsync({
13741388 cwd: activeProject.cwd,
1375 branch: activeThread.branch,
1389 branch: baseBranchForWorktree,
13761390 newBranch,
13771391 });
13781392 await api.orchestration.dispatchCommand({
13791393 type: "thread.meta.update",
13801394 commandId: newCommandId(),
1381 threadId: activeThread.id,
1395 threadId: threadIdForSend,
13821396 branch: result.worktree.branch,
13831397 worktreePath: result.worktree.path,
13841398 });
......@@ -1390,43 +1404,25 @@ export default function ChatView({ threadId }: ChatViewProps) {
13901404 rememberAsLastInvoked: false,
13911405 });
13921406 }
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;
14001407 }
1401 }
14021408
1403 // Auto-title from first message
1404 if (activeThread.messages.length === 0) {
1405 const titleSeed =
1406 trimmed ||
1407 (composerImagesSnapshot.length > 0
1408 ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
1409 : "New thread");
1410 const title = truncateTitle(titleSeed);
1411 if (api) {
1409 // Auto-title from first message
1410 if (isFirstMessage) {
1411 const titleSeed =
1412 trimmed ||
1413 (composerImagesSnapshot.length > 0
1414 ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
1415 : "New thread");
1416 const title = truncateTitle(titleSeed);
14121417 await api.orchestration.dispatchCommand({
14131418 type: "thread.meta.update",
14141419 commandId: newCommandId(),
1415 threadId: activeThread.id,
1420 threadId: threadIdForSend,
14161421 title,
14171422 });
14181423 }
1419 }
14201424
1421 setThreadError(activeThread.id, null);
1422 promptRef.current = "";
1423 setPrompt("");
1424 setComposerImages([]);
1425 setComposerCursor(0);
1426 setComposerHighlightedItemId(null);
1427
1428 setIsSending(true);
1429 try {
1425 setSendPhase("sending-turn");
14301426 const turnAttachments = await Promise.all(
14311427 composerImagesSnapshot.map(
14321428 async (
......@@ -1446,10 +1442,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
14461442 }),
14471443 ),
14481444 );
1445 attemptedTurnStart = true;
14491446 await api.orchestration.dispatchCommand({
14501447 type: "thread.turn.start",
14511448 commandId: newCommandId(),
1452 threadId: activeThread.id,
1449 threadId: threadIdForSend,
14531450 message: {
14541451 messageId: newMessageId(),
14551452 role: "user",
......@@ -1461,12 +1458,22 @@ export default function ChatView({ threadId }: ChatViewProps) {
14611458 createdAt: new Date().toISOString(),
14621459 });
14631460 } 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 }
14641471 setThreadError(
1465 activeThread.id,
1472 threadIdForSend,
14661473 err instanceof Error ? err.message : "Failed to send message.",
14671474 );
14681475 } finally {
1469 setIsSending(false);
1476 setSendPhase("idle");
14701477 }
14711478 };
14721479
......@@ -1851,7 +1858,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
18511858 ? "Ask for follow-up changes or attach images"
18521859 : "Ask anything, @tag files/folders, or use /model"
18531860 }
1854 disabled={isSending || isConnecting}
1861 disabled={isConnecting}
18551862 />
18561863 </div>
18571864
......@@ -1897,6 +1904,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
18971904
18981905 {/* Right side: send / stop button */}
18991906 <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}
19001910 {phase === "running" ? (
19011911 <button
19021912 type="button"
......@@ -1919,13 +1929,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
19191929 type="submit"
19201930 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"
19211931 disabled={
1922 isSending || isConnecting || (!prompt.trim() && composerImages.length === 0)
1932 isSendBusy || isConnecting || (!prompt.trim() && composerImages.length === 0)
19231933 }
19241934 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"
19261942 }
19271943 >
1928 {isConnecting || isSending ? (
1944 {isConnecting || isSendBusy ? (
19291945 <svg
19301946 width="14"
19311947 height="14"