| ... | @@ -188,6 +188,8 @@ type ComposerCommandItem = | ... | @@ -188,6 +188,8 @@ type ComposerCommandItem = |
| 188 | description: string; | 188 | description: string; |
| 189 | }; | 189 | }; |
| 190 | | 190 | |
| | 191 | type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; |
| | 192 | |
| 191 | function readFileAsDataUrl(file: File): Promise<string> { | 193 | function 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; |
| 1317 | | 1321 | |
| 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 | ); |
| 1354 | | 1358 | |
| 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]; |
| 1363 | | 1373 | |
| 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.worktreePath | 1379 | 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 | } | | |
| 1402 | | 1408 | |
| 1403 | // Auto-title from first message | 1409 | // 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 > 0 | 1413 | (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 | } | | |
| 1420 | | 1424 | |
| 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 | }; |
| 1472 | | 1479 | |
| ... | @@ -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> |
| 1857 | | 1864 | |
| ... | @@ -1897,6 +1904,9 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1897,6 +1904,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1897 | | 1904 | |
| 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 | <button | 1911 | <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 | <svg | 1945 | <svg |
| 1930 | width="14" | 1946 | width="14" |
| 1931 | height="14" | 1947 | height="14" |