| ... | ... | @@ -188,6 +188,8 @@ type ComposerCommandItem = |
| 188 | 188 | description: string; |
| 189 | 189 | }; |
| 190 | 190 | |
| 191 | type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; |
| 192 | |
| 191 | 193 | function readFileAsDataUrl(file: File): Promise<string> { |
| 192 | 194 | return new Promise((resolve, reject) => { |
| 193 | 195 | const reader = new FileReader(); |
| ... | ... | @@ -342,7 +344,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 342 | 344 | const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); |
| 343 | 345 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); |
| 344 | 346 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| 345 | | const [isSending, setIsSending] = useState(false); |
| 347 | const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); |
| 346 | 348 | const [isConnecting, setIsConnecting] = useState(false); |
| 347 | 349 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); |
| 348 | 350 | const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); |
| ... | ... | @@ -397,7 +399,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 397 | 399 | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, |
| 398 | 400 | ); |
| 399 | 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 | 405 | const nowIso = new Date(nowTick).toISOString(); |
| 402 | 406 | const threadActivities = activeThread?.activities ?? []; |
| 403 | 407 | const workLogEntries = useMemo( |
| ... | ... | @@ -1026,7 +1030,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1026 | 1030 | }); |
| 1027 | 1031 | setPrompt(""); |
| 1028 | 1032 | promptRef.current = ""; |
| 1029 | | setIsSending(false); |
| 1033 | setSendPhase("idle"); |
| 1030 | 1034 | setComposerCursor(0); |
| 1031 | 1035 | setComposerHighlightedItemId(null); |
| 1032 | 1036 | dragDepthRef.current = 0; |
| ... | ... | @@ -1315,7 +1319,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1315 | 1319 | const api = readNativeApi(); |
| 1316 | 1320 | if (!api || !activeThread || isRevertingCheckpoint) return; |
| 1317 | 1321 | |
| 1318 | | if (phase === "running" || isSending || isConnecting) { |
| 1322 | if (phase === "running" || isSendBusy || isConnecting) { |
| 1319 | 1323 | setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); |
| 1320 | 1324 | return; |
| 1321 | 1325 | } |
| ... | ... | @@ -1349,36 +1353,46 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1349 | 1353 | setIsRevertingCheckpoint(false); |
| 1350 | 1354 | } |
| 1351 | 1355 | }, |
| 1352 | | [activeThread, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError], |
| 1356 | [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError], |
| 1353 | 1357 | ); |
| 1354 | 1358 | |
| 1355 | 1359 | const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => { |
| 1356 | 1360 | e.preventDefault(); |
| 1357 | 1361 | const api = readNativeApi(); |
| 1358 | | if (!api || !activeThread || isSending || isConnecting) return; |
| 1362 | if (!api || !activeThread || isSendBusy || isConnecting) return; |
| 1359 | 1363 | const trimmed = prompt.trim(); |
| 1360 | 1364 | if (!trimmed && composerImages.length === 0) return; |
| 1361 | 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 | 1372 | const composerImagesSnapshot = [...composerImages]; |
| 1363 | 1373 | |
| 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"); |
| 1372 | 1386 | const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`; |
| 1373 | 1387 | const result = await createWorktreeMutation.mutateAsync({ |
| 1374 | 1388 | cwd: activeProject.cwd, |
| 1375 | | branch: activeThread.branch, |
| 1389 | branch: baseBranchForWorktree, |
| 1376 | 1390 | newBranch, |
| 1377 | 1391 | }); |
| 1378 | 1392 | await api.orchestration.dispatchCommand({ |
| 1379 | 1393 | type: "thread.meta.update", |
| 1380 | 1394 | commandId: newCommandId(), |
| 1381 | | threadId: activeThread.id, |
| 1395 | threadId: threadIdForSend, |
| 1382 | 1396 | branch: result.worktree.branch, |
| 1383 | 1397 | worktreePath: result.worktree.path, |
| 1384 | 1398 | }); |
| ... | ... | @@ -1390,43 +1404,25 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1390 | 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 | | } |
| 1402 | 1408 | |
| 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); |
| 1412 | 1417 | await api.orchestration.dispatchCommand({ |
| 1413 | 1418 | type: "thread.meta.update", |
| 1414 | 1419 | commandId: newCommandId(), |
| 1415 | | threadId: activeThread.id, |
| 1420 | threadId: threadIdForSend, |
| 1416 | 1421 | title, |
| 1417 | 1422 | }); |
| 1418 | 1423 | } |
| 1419 | | } |
| 1420 | 1424 | |
| 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"); |
| 1430 | 1426 | const turnAttachments = await Promise.all( |
| 1431 | 1427 | composerImagesSnapshot.map( |
| 1432 | 1428 | async ( |
| ... | ... | @@ -1446,10 +1442,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1446 | 1442 | }), |
| 1447 | 1443 | ), |
| 1448 | 1444 | ); |
| 1445 | attemptedTurnStart = true; |
| 1449 | 1446 | await api.orchestration.dispatchCommand({ |
| 1450 | 1447 | type: "thread.turn.start", |
| 1451 | 1448 | commandId: newCommandId(), |
| 1452 | | threadId: activeThread.id, |
| 1449 | threadId: threadIdForSend, |
| 1453 | 1450 | message: { |
| 1454 | 1451 | messageId: newMessageId(), |
| 1455 | 1452 | role: "user", |
| ... | ... | @@ -1461,12 +1458,22 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1461 | 1458 | createdAt: new Date().toISOString(), |
| 1462 | 1459 | }); |
| 1463 | 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 | 1471 | setThreadError( |
| 1465 | | activeThread.id, |
| 1472 | threadIdForSend, |
| 1466 | 1473 | err instanceof Error ? err.message : "Failed to send message.", |
| 1467 | 1474 | ); |
| 1468 | 1475 | } finally { |
| 1469 | | setIsSending(false); |
| 1476 | setSendPhase("idle"); |
| 1470 | 1477 | } |
| 1471 | 1478 | }; |
| 1472 | 1479 | |
| ... | ... | @@ -1851,7 +1858,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1851 | 1858 | ? "Ask for follow-up changes or attach images" |
| 1852 | 1859 | : "Ask anything, @tag files/folders, or use /model" |
| 1853 | 1860 | } |
| 1854 | | disabled={isSending || isConnecting} |
| 1861 | disabled={isConnecting} |
| 1855 | 1862 | /> |
| 1856 | 1863 | </div> |
| 1857 | 1864 | |
| ... | ... | @@ -1897,6 +1904,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1897 | 1904 | |
| 1898 | 1905 | {/* Right side: send / stop button */} |
| 1899 | 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 | 1910 | {phase === "running" ? ( |
| 1901 | 1911 | <button |
| 1902 | 1912 | type="button" |
| ... | ... | @@ -1919,13 +1929,19 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1919 | 1929 | type="submit" |
| 1920 | 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 | 1931 | disabled={ |
| 1922 | | isSending || isConnecting || (!prompt.trim() && composerImages.length === 0) |
| 1932 | isSendBusy || isConnecting || (!prompt.trim() && composerImages.length === 0) |
| 1923 | 1933 | } |
| 1924 | 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 | 1945 | <svg |
| 1930 | 1946 | width="14" |
| 1931 | 1947 | height="14" |