Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 128 additions & 58 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ import { truncateTitle } from "../truncateTitle";
import {
DEFAULT_THREAD_TERMINAL_ID,
MAX_THREAD_TERMINAL_COUNT,
type ChatMessage,
type ChatImageAttachment,
type TurnDiffSummary,
} from "../types";
Expand DownExpand Up@@ -188,6 +189,8 @@ type ComposerCommandItem =
description: string;
};

type SendPhase = "idle" | "preparing-worktree" | "sending-turn";

function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
Expand DownExpand Up@@ -342,8 +345,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
const [isDragOverComposer, setIsDragOverComposer] = useState(false);
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
const [isSending, setIsSending] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
const [isConnecting, _setIsConnecting] = useState(false);
const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);
const [envMode, setEnvMode] = useState<"local" | "worktree">("local");
Expand DownExpand Up@@ -397,7 +401,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
);
const phase = derivePhase(activeThread?.session ?? null);
const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint;
const isSendBusy = sendPhase !== "idle";
const isPreparingWorktree = sendPhase === "preparing-worktree";
const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
const nowIso = new Date(nowTick).toISOString();
const threadActivities = activeThread?.activities ?? [];
const workLogEntries = useMemo(
Expand All@@ -417,9 +423,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
() => derivePendingApprovals(threadActivities),
[threadActivities],
);
const timelineMessages = useMemo(() => {
const serverMessages = activeThread?.messages ?? [];
if (optimisticUserMessages.length === 0) {
return serverMessages;
}
const serverIds = new Set(serverMessages.map((message) => message.id));
const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id));
if (pendingMessages.length === 0) {
return serverMessages;
}
return [...serverMessages, ...pendingMessages];
}, [activeThread?.messages, optimisticUserMessages]);
const timelineEntries = useMemo(
() => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries),
[activeThread?.messages, workLogEntries],
() => deriveTimelineEntries(timelineMessages, workLogEntries),
[timelineMessages, workLogEntries],
);
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);
Expand DownExpand Up@@ -960,7 +978,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
}, [lastInvokedScriptByProjectId]);

// Auto-scroll on new messages
const messageCount = activeThread?.messages.length ?? 0;
const messageCount = timelineMessages.length;
const workLogCount = workLogEntries.length;
const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
const scrollContainer = messagesScrollRef.current;
Expand DownExpand Up@@ -1019,14 +1037,30 @@ export default function ChatView({ threadId }: ChatViewProps) {
composerImagesRef.current = composerImages;
}, [composerImages]);

useEffect(() => {
if (!activeThread?.id) {
setOptimisticUserMessages([]);
return;
}
if (activeThread.messages.length === 0) {
return;
}
const serverIds = new Set(activeThread.messages.map((message) => message.id));
setOptimisticUserMessages((existing) => {
const next = existing.filter((message) => !serverIds.has(message.id));
return next.length === existing.length ? existing : next;
});
}, [activeThread?.id, activeThread?.messages]);

useEffect(() => {
setComposerImages((existing) => {
revokePreviewUrls(existing);
return [];
});
setOptimisticUserMessages([]);
setPrompt("");
promptRef.current = "";
setIsSending(false);
setSendPhase("idle");
setComposerCursor(0);
setComposerHighlightedItemId(null);
dragDepthRef.current = 0;
Expand DownExpand Up@@ -1315,7 +1349,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
const api = readNativeApi();
if (!api || !activeThread || isRevertingCheckpoint) return;

if (phase === "running" || isSending || isConnecting) {
if (phase === "running" || isSendBusy || isConnecting) {
setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
return;
}
Expand DownExpand Up@@ -1349,36 +1383,67 @@ export default function ChatView({ threadId }: ChatViewProps) {
setIsRevertingCheckpoint(false);
}
},
[activeThread, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError],
[activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
);

const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => {
e.preventDefault();
const api = readNativeApi();
if (!api || !activeThread || isSending || isConnecting) return;
if (!api || !activeThread || isSendBusy || isConnecting) return;
const trimmed = prompt.trim();
if (!trimmed && composerImages.length === 0) return;
if (!activeProject) return;
const threadIdForSend = activeThread.id;
const isFirstMessage = activeThread.messages.length === 0;
const baseBranchForWorktree =
isFirstMessage && envMode === "worktree" && !activeThread.worktreePath
? activeThread.branch
: null;
const composerImagesSnapshot = [...composerImages];
const messageIdForSend = newMessageId();
const messageCreatedAt = new Date().toISOString();
const optimisticAttachments = composerImagesSnapshot.map((image) => ({
type: "image" as const,
id: image.id,
name: image.name,
mimeType: image.mimeType,
sizeBytes: image.sizeBytes,
previewUrl: image.previewUrl,
}));
setOptimisticUserMessages((existing) => [
...existing,
{
id: messageIdForSend,
role: "user",
text: trimmed,
...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}),
createdAt: messageCreatedAt,
streaming: false,
},
]);

// On first message: lock in branch + create worktree if needed.
if (
activeThread.messages.length === 0 &&
activeThread.branch &&
envMode === "worktree" &&
!activeThread.worktreePath
) {
try {
setThreadError(threadIdForSend, null);
promptRef.current = "";
setPrompt("");
setComposerImages([]);
setComposerCursor(0);
setComposerHighlightedItemId(null);

let attemptedTurnStart = false;
try {
// On first message: lock in branch + create worktree if needed.
if (baseBranchForWorktree) {
setSendPhase("preparing-worktree");
const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`;
const result = await createWorktreeMutation.mutateAsync({
cwd: activeProject.cwd,
branch: activeThread.branch,
branch: baseBranchForWorktree,
newBranch,
});
await api.orchestration.dispatchCommand({
type: "thread.meta.update",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
branch: result.worktree.branch,
worktreePath: result.worktree.path,
});
Expand All@@ -1390,43 +1455,25 @@ export default function ChatView({ threadId }: ChatViewProps) {
rememberAsLastInvoked: false,
});
}
} catch (err) {
dispatch({
type: "SET_ERROR",
threadId: activeThread.id,
error: err instanceof Error ? err.message : "Failed to create worktree",
});
return;
}
}

// Auto-title from first message
if (activeThread.messages.length === 0) {
const titleSeed =
trimmed ||
(composerImagesSnapshot.length > 0
? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
: "New thread");
const title = truncateTitle(titleSeed);
if (api) {
// Auto-title from first message
if (isFirstMessage) {
const titleSeed =
trimmed ||
(composerImagesSnapshot.length > 0
? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
: "New thread");
const title = truncateTitle(titleSeed);
await api.orchestration.dispatchCommand({
type: "thread.meta.update",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
title,
});
}
}

setThreadError(activeThread.id, null);
promptRef.current = "";
setPrompt("");
setComposerImages([]);
setComposerCursor(0);
setComposerHighlightedItemId(null);

setIsSending(true);
try {
setSendPhase("sending-turn");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Send phase not set before first await

Medium Severity

For non-worktree first messages, the composer is cleared (lines 1350–1355) but setSendPhase is not called until line 1401, after the auto-title await. During this gap, sendPhase remains "idle", so isSendBusy is false. Because the textarea is now always enabled during sends (disabled={isConnecting}), a user could type a new message and submit it before setSendPhase("sending-turn") fires, bypassing the isSendBusy guard and causing concurrent turn dispatches.

Additional Locations (1)

Fix in CursorFix in Web

const turnAttachments = await Promise.all(
composerImagesSnapshot.map(
async (
Expand All@@ -1446,27 +1493,41 @@ export default function ChatView({ threadId }: ChatViewProps) {
}),
),
);
attemptedTurnStart = true;
await api.orchestration.dispatchCommand({
type: "thread.turn.start",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
message: {
messageId: newMessageId(),
messageId: messageIdForSend,
role: "user",
text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT,
attachments: turnAttachments,
},
model: selectedModel || undefined,
effort: selectedEffort || undefined,
createdAt: new Date().toISOString(),
createdAt: messageCreatedAt,
});
} catch (err) {
if (
!attemptedTurnStart &&
promptRef.current.length === 0 &&
composerImagesRef.current.length === 0
) {
setOptimisticUserMessages((existing) =>
existing.filter((message) => message.id !== messageIdForSend),
);
promptRef.current = trimmed;
setPrompt(trimmed);
setComposerImages(composerImagesSnapshot);
setComposerCursor(trimmed.length);
}
Comment thread
juliusmarminge marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optimistic message persists as ghost after failed dispatch

Medium Severity

When attemptedTurnStart is true but thread.turn.start dispatch fails (e.g., network error), the optimistic message is never removed from optimisticUserMessages. The catch block only cleans up when !attemptedTurnStart, and the cleanup effect at line 1045 early-returns when activeThread.messages.length === 0 (first-message case), so the ghost message persists in timelineMessages indefinitely until the user switches threads. For follow-up messages, the cleanup effect also can't remove it since the server never received the message ID. This results in a permanently visible "sent" message that was never actually delivered.

Additional Locations (1)

Fix in CursorFix in Web

setThreadError(
activeThread.id,
threadIdForSend,
err instanceof Error ? err.message : "Failed to send message.",
);
} finally {
setIsSending(false);
setSendPhase("idle");
}
};

Expand DownExpand Up@@ -1733,7 +1794,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
onScroll={onMessagesScroll}
>
<MessagesTimeline
hasMessages={activeThread.messages.length > 0}
hasMessages={timelineMessages.length > 0}
isWorking={isWorking}
scrollContainerRef={messagesScrollRef}
timelineEntries={timelineEntries}
Expand DownExpand Up@@ -1851,7 +1912,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
? "Ask for follow-up changes or attach images"
: "Ask anything, @tag files/folders, or use /model"
}
disabled={isSending || isConnecting}
disabled={isConnecting}
/>
</div>

Expand DownExpand Up@@ -1897,6 +1958,9 @@ export default function ChatView({ threadId }: ChatViewProps) {

{/* Right side: send / stop button */}
<div className="flex shrink-0 items-center gap-2">
{isPreparingWorktree ? (
<span className="text-muted-foreground/70 text-xs">Preparing worktree...</span>
) : null}
{phase === "running" ? (
<button
type="button"
Expand All@@ -1919,13 +1983,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
type="submit"
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"
disabled={
isSending || isConnecting || (!prompt.trim() && composerImages.length === 0)
isSendBusy || isConnecting || (!prompt.trim() && composerImages.length === 0)
}
aria-label={
isConnecting ? "Connecting" : isSending ? "Sending" : "Send message"
isConnecting
? "Connecting"
: isPreparingWorktree
? "Preparing worktree"
: isSendBusy
? "Sending"
: "Send message"
}
>
{isConnecting || isSending ? (
{isConnecting || isSendBusy ? (
<svg
width="14"
height="14"
Expand Down
5 changes: 3 additions & 2 deletions scripts/dev-runner.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -154,12 +154,13 @@ export function parseDevRunnerArgs(rawArgs) {
function resolveStateDir(env, envOverrides) {
const overrideValue = envOverrides?.T3CODE_STATE_DIR?.trim();
if (overrideValue) {
return overrideValue;
// Resolve relative paths against cwd (monorepo root) before turbo changes directories
return path.resolve(overrideValue);
}

const envValue = env?.T3CODE_STATE_DIR?.trim();
if (envValue) {
return envValue;
return path.resolve(envValue);
}

return DEFAULT_DEV_STATE_DIR;
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 128 additions & 58 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ import { truncateTitle } from "../truncateTitle";
import {
DEFAULT_THREAD_TERMINAL_ID,
MAX_THREAD_TERMINAL_COUNT,
type ChatMessage,
type ChatImageAttachment,
type TurnDiffSummary,
} from "../types";
Expand DownExpand Up@@ -188,6 +189,8 @@ type ComposerCommandItem =
description: string;
};

type SendPhase = "idle" | "preparing-worktree" | "sending-turn";

function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
Expand DownExpand Up@@ -342,8 +345,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
const [isDragOverComposer, setIsDragOverComposer] = useState(false);
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
const [isSending, setIsSending] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
const [isConnecting, _setIsConnecting] = useState(false);
const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);
const [envMode, setEnvMode] = useState<"local" | "worktree">("local");
Expand DownExpand Up@@ -397,7 +401,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
);
const phase = derivePhase(activeThread?.session ?? null);
const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint;
const isSendBusy = sendPhase !== "idle";
const isPreparingWorktree = sendPhase === "preparing-worktree";
const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
const nowIso = new Date(nowTick).toISOString();
const threadActivities = activeThread?.activities ?? [];
const workLogEntries = useMemo(
Expand All@@ -417,9 +423,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
() => derivePendingApprovals(threadActivities),
[threadActivities],
);
const timelineMessages = useMemo(() => {
const serverMessages = activeThread?.messages ?? [];
if (optimisticUserMessages.length === 0) {
return serverMessages;
}
const serverIds = new Set(serverMessages.map((message) => message.id));
const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id));
if (pendingMessages.length === 0) {
return serverMessages;
}
return [...serverMessages, ...pendingMessages];
}, [activeThread?.messages, optimisticUserMessages]);
const timelineEntries = useMemo(
() => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries),
[activeThread?.messages, workLogEntries],
() => deriveTimelineEntries(timelineMessages, workLogEntries),
[timelineMessages, workLogEntries],
);
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);
Expand DownExpand Up@@ -960,7 +978,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
}, [lastInvokedScriptByProjectId]);

// Auto-scroll on new messages
const messageCount = activeThread?.messages.length ?? 0;
const messageCount = timelineMessages.length;
const workLogCount = workLogEntries.length;
const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
const scrollContainer = messagesScrollRef.current;
Expand DownExpand Up@@ -1019,14 +1037,30 @@ export default function ChatView({ threadId }: ChatViewProps) {
composerImagesRef.current = composerImages;
}, [composerImages]);

useEffect(() => {
if (!activeThread?.id) {
setOptimisticUserMessages([]);
return;
}
if (activeThread.messages.length === 0) {
return;
}
const serverIds = new Set(activeThread.messages.map((message) => message.id));
setOptimisticUserMessages((existing) => {
const next = existing.filter((message) => !serverIds.has(message.id));
return next.length === existing.length ? existing : next;
});
}, [activeThread?.id, activeThread?.messages]);

useEffect(() => {
setComposerImages((existing) => {
revokePreviewUrls(existing);
return [];
});
setOptimisticUserMessages([]);
setPrompt("");
promptRef.current = "";
setIsSending(false);
setSendPhase("idle");
setComposerCursor(0);
setComposerHighlightedItemId(null);
dragDepthRef.current = 0;
Expand DownExpand Up@@ -1315,7 +1349,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
const api = readNativeApi();
if (!api || !activeThread || isRevertingCheckpoint) return;

if (phase === "running" || isSending || isConnecting) {
if (phase === "running" || isSendBusy || isConnecting) {
setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
return;
}
Expand DownExpand Up@@ -1349,36 +1383,67 @@ export default function ChatView({ threadId }: ChatViewProps) {
setIsRevertingCheckpoint(false);
}
},
[activeThread, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError],
[activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
);

const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => {
e.preventDefault();
const api = readNativeApi();
if (!api || !activeThread || isSending || isConnecting) return;
if (!api || !activeThread || isSendBusy || isConnecting) return;
const trimmed = prompt.trim();
if (!trimmed && composerImages.length === 0) return;
if (!activeProject) return;
const threadIdForSend = activeThread.id;
const isFirstMessage = activeThread.messages.length === 0;
const baseBranchForWorktree =
isFirstMessage && envMode === "worktree" && !activeThread.worktreePath
? activeThread.branch
: null;
const composerImagesSnapshot = [...composerImages];
const messageIdForSend = newMessageId();
const messageCreatedAt = new Date().toISOString();
const optimisticAttachments = composerImagesSnapshot.map((image) => ({
type: "image" as const,
id: image.id,
name: image.name,
mimeType: image.mimeType,
sizeBytes: image.sizeBytes,
previewUrl: image.previewUrl,
}));
setOptimisticUserMessages((existing) => [
...existing,
{
id: messageIdForSend,
role: "user",
text: trimmed,
...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}),
createdAt: messageCreatedAt,
streaming: false,
},
]);

// On first message: lock in branch + create worktree if needed.
if (
activeThread.messages.length === 0 &&
activeThread.branch &&
envMode === "worktree" &&
!activeThread.worktreePath
) {
try {
setThreadError(threadIdForSend, null);
promptRef.current = "";
setPrompt("");
setComposerImages([]);
setComposerCursor(0);
setComposerHighlightedItemId(null);

let attemptedTurnStart = false;
try {
// On first message: lock in branch + create worktree if needed.
if (baseBranchForWorktree) {
setSendPhase("preparing-worktree");
const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`;
const result = await createWorktreeMutation.mutateAsync({
cwd: activeProject.cwd,
branch: activeThread.branch,
branch: baseBranchForWorktree,
newBranch,
});
await api.orchestration.dispatchCommand({
type: "thread.meta.update",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
branch: result.worktree.branch,
worktreePath: result.worktree.path,
});
Expand All@@ -1390,43 +1455,25 @@ export default function ChatView({ threadId }: ChatViewProps) {
rememberAsLastInvoked: false,
});
}
} catch (err) {
dispatch({
type: "SET_ERROR",
threadId: activeThread.id,
error: err instanceof Error ? err.message : "Failed to create worktree",
});
return;
}
}

// Auto-title from first message
if (activeThread.messages.length === 0) {
const titleSeed =
trimmed ||
(composerImagesSnapshot.length > 0
? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
: "New thread");
const title = truncateTitle(titleSeed);
if (api) {
// Auto-title from first message
if (isFirstMessage) {
const titleSeed =
trimmed ||
(composerImagesSnapshot.length > 0
? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
: "New thread");
const title = truncateTitle(titleSeed);
await api.orchestration.dispatchCommand({
type: "thread.meta.update",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
title,
});
}
}

setThreadError(activeThread.id, null);
promptRef.current = "";
setPrompt("");
setComposerImages([]);
setComposerCursor(0);
setComposerHighlightedItemId(null);

setIsSending(true);
try {
setSendPhase("sending-turn");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Send phase not set before first await

Medium Severity

For non-worktree first messages, the composer is cleared (lines 1350–1355) but setSendPhase is not called until line 1401, after the auto-title await. During this gap, sendPhase remains "idle", so isSendBusy is false. Because the textarea is now always enabled during sends (disabled={isConnecting}), a user could type a new message and submit it before setSendPhase("sending-turn") fires, bypassing the isSendBusy guard and causing concurrent turn dispatches.

Additional Locations (1)

Fix in CursorFix in Web

const turnAttachments = await Promise.all(
composerImagesSnapshot.map(
async (
Expand All@@ -1446,27 +1493,41 @@ export default function ChatView({ threadId }: ChatViewProps) {
}),
),
);
attemptedTurnStart = true;
await api.orchestration.dispatchCommand({
type: "thread.turn.start",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
message: {
messageId: newMessageId(),
messageId: messageIdForSend,
role: "user",
text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT,
attachments: turnAttachments,
},
model: selectedModel || undefined,
effort: selectedEffort || undefined,
createdAt: new Date().toISOString(),
createdAt: messageCreatedAt,
});
} catch (err) {
if (
!attemptedTurnStart &&
promptRef.current.length === 0 &&
composerImagesRef.current.length === 0
) {
setOptimisticUserMessages((existing) =>
existing.filter((message) => message.id !== messageIdForSend),
);
promptRef.current = trimmed;
setPrompt(trimmed);
setComposerImages(composerImagesSnapshot);
setComposerCursor(trimmed.length);
}
Comment thread
juliusmarminge marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optimistic message persists as ghost after failed dispatch

Medium Severity

When attemptedTurnStart is true but thread.turn.start dispatch fails (e.g., network error), the optimistic message is never removed from optimisticUserMessages. The catch block only cleans up when !attemptedTurnStart, and the cleanup effect at line 1045 early-returns when activeThread.messages.length === 0 (first-message case), so the ghost message persists in timelineMessages indefinitely until the user switches threads. For follow-up messages, the cleanup effect also can't remove it since the server never received the message ID. This results in a permanently visible "sent" message that was never actually delivered.

Additional Locations (1)

Fix in CursorFix in Web

setThreadError(
activeThread.id,
threadIdForSend,
err instanceof Error ? err.message : "Failed to send message.",
);
} finally {
setIsSending(false);
setSendPhase("idle");
}
};

Expand DownExpand Up@@ -1733,7 +1794,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
onScroll={onMessagesScroll}
>
<MessagesTimeline
hasMessages={activeThread.messages.length > 0}
hasMessages={timelineMessages.length > 0}
isWorking={isWorking}
scrollContainerRef={messagesScrollRef}
timelineEntries={timelineEntries}
Expand DownExpand Up@@ -1851,7 +1912,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
? "Ask for follow-up changes or attach images"
: "Ask anything, @tag files/folders, or use /model"
}
disabled={isSending || isConnecting}
disabled={isConnecting}
/>
</div>

Expand DownExpand Up@@ -1897,6 +1958,9 @@ export default function ChatView({ threadId }: ChatViewProps) {

{/* Right side: send / stop button */}
<div className="flex shrink-0 items-center gap-2">
{isPreparingWorktree ? (
<span className="text-muted-foreground/70 text-xs">Preparing worktree...</span>
) : null}
{phase === "running" ? (
<button
type="button"
Expand All@@ -1919,13 +1983,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
type="submit"
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"
disabled={
isSending || isConnecting || (!prompt.trim() && composerImages.length === 0)
isSendBusy || isConnecting || (!prompt.trim() && composerImages.length === 0)
}
aria-label={
isConnecting ? "Connecting" : isSending ? "Sending" : "Send message"
isConnecting
? "Connecting"
: isPreparingWorktree
? "Preparing worktree"
: isSendBusy
? "Sending"
: "Send message"
}
>
{isConnecting || isSending ? (
{isConnecting || isSendBusy ? (
<svg
width="14"
height="14"
Expand Down
5 changes: 3 additions & 2 deletions scripts/dev-runner.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -154,12 +154,13 @@ export function parseDevRunnerArgs(rawArgs) {
function resolveStateDir(env, envOverrides) {
const overrideValue = envOverrides?.T3CODE_STATE_DIR?.trim();
if (overrideValue) {
return overrideValue;
// Resolve relative paths against cwd (monorepo root) before turbo changes directories
return path.resolve(overrideValue);
}

const envValue = env?.T3CODE_STATE_DIR?.trim();
if (envValue) {
return envValue;
return path.resolve(envValue);
}

return DEFAULT_DEV_STATE_DIR;
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 128 additions & 58 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ import { truncateTitle } from "../truncateTitle";
import {
DEFAULT_THREAD_TERMINAL_ID,
MAX_THREAD_TERMINAL_COUNT,
type ChatMessage,
type ChatImageAttachment,
type TurnDiffSummary,
} from "../types";
Expand DownExpand Up@@ -188,6 +189,8 @@ type ComposerCommandItem =
description: string;
};

type SendPhase = "idle" | "preparing-worktree" | "sending-turn";

function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
Expand DownExpand Up@@ -342,8 +345,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
const [isDragOverComposer, setIsDragOverComposer] = useState(false);
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
const [isSending, setIsSending] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
const [isConnecting, _setIsConnecting] = useState(false);
const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);
const [envMode, setEnvMode] = useState<"local" | "worktree">("local");
Expand DownExpand Up@@ -397,7 +401,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
);
const phase = derivePhase(activeThread?.session ?? null);
const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint;
const isSendBusy = sendPhase !== "idle";
const isPreparingWorktree = sendPhase === "preparing-worktree";
const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
const nowIso = new Date(nowTick).toISOString();
const threadActivities = activeThread?.activities ?? [];
const workLogEntries = useMemo(
Expand All@@ -417,9 +423,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
() => derivePendingApprovals(threadActivities),
[threadActivities],
);
const timelineMessages = useMemo(() => {
const serverMessages = activeThread?.messages ?? [];
if (optimisticUserMessages.length === 0) {
return serverMessages;
}
const serverIds = new Set(serverMessages.map((message) => message.id));
const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id));
if (pendingMessages.length === 0) {
return serverMessages;
}
return [...serverMessages, ...pendingMessages];
}, [activeThread?.messages, optimisticUserMessages]);
const timelineEntries = useMemo(
() => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries),
[activeThread?.messages, workLogEntries],
() => deriveTimelineEntries(timelineMessages, workLogEntries),
[timelineMessages, workLogEntries],
);
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);
Expand DownExpand Up@@ -960,7 +978,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
}, [lastInvokedScriptByProjectId]);

// Auto-scroll on new messages
const messageCount = activeThread?.messages.length ?? 0;
const messageCount = timelineMessages.length;
const workLogCount = workLogEntries.length;
const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
const scrollContainer = messagesScrollRef.current;
Expand DownExpand Up@@ -1019,14 +1037,30 @@ export default function ChatView({ threadId }: ChatViewProps) {
composerImagesRef.current = composerImages;
}, [composerImages]);

useEffect(() => {
if (!activeThread?.id) {
setOptimisticUserMessages([]);
return;
}
if (activeThread.messages.length === 0) {
return;
}
const serverIds = new Set(activeThread.messages.map((message) => message.id));
setOptimisticUserMessages((existing) => {
const next = existing.filter((message) => !serverIds.has(message.id));
return next.length === existing.length ? existing : next;
});
}, [activeThread?.id, activeThread?.messages]);

useEffect(() => {
setComposerImages((existing) => {
revokePreviewUrls(existing);
return [];
});
setOptimisticUserMessages([]);
setPrompt("");
promptRef.current = "";
setIsSending(false);
setSendPhase("idle");
setComposerCursor(0);
setComposerHighlightedItemId(null);
dragDepthRef.current = 0;
Expand DownExpand Up@@ -1315,7 +1349,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
const api = readNativeApi();
if (!api || !activeThread || isRevertingCheckpoint) return;

if (phase === "running" || isSending || isConnecting) {
if (phase === "running" || isSendBusy || isConnecting) {
setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
return;
}
Expand DownExpand Up@@ -1349,36 +1383,67 @@ export default function ChatView({ threadId }: ChatViewProps) {
setIsRevertingCheckpoint(false);
}
},
[activeThread, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError],
[activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
);

const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => {
e.preventDefault();
const api = readNativeApi();
if (!api || !activeThread || isSending || isConnecting) return;
if (!api || !activeThread || isSendBusy || isConnecting) return;
const trimmed = prompt.trim();
if (!trimmed && composerImages.length === 0) return;
if (!activeProject) return;
const threadIdForSend = activeThread.id;
const isFirstMessage = activeThread.messages.length === 0;
const baseBranchForWorktree =
isFirstMessage && envMode === "worktree" && !activeThread.worktreePath
? activeThread.branch
: null;
const composerImagesSnapshot = [...composerImages];
const messageIdForSend = newMessageId();
const messageCreatedAt = new Date().toISOString();
const optimisticAttachments = composerImagesSnapshot.map((image) => ({
type: "image" as const,
id: image.id,
name: image.name,
mimeType: image.mimeType,
sizeBytes: image.sizeBytes,
previewUrl: image.previewUrl,
}));
setOptimisticUserMessages((existing) => [
...existing,
{
id: messageIdForSend,
role: "user",
text: trimmed,
...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}),
createdAt: messageCreatedAt,
streaming: false,
},
]);

// On first message: lock in branch + create worktree if needed.
if (
activeThread.messages.length === 0 &&
activeThread.branch &&
envMode === "worktree" &&
!activeThread.worktreePath
) {
try {
setThreadError(threadIdForSend, null);
promptRef.current = "";
setPrompt("");
setComposerImages([]);
setComposerCursor(0);
setComposerHighlightedItemId(null);

let attemptedTurnStart = false;
try {
// On first message: lock in branch + create worktree if needed.
if (baseBranchForWorktree) {
setSendPhase("preparing-worktree");
const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`;
const result = await createWorktreeMutation.mutateAsync({
cwd: activeProject.cwd,
branch: activeThread.branch,
branch: baseBranchForWorktree,
newBranch,
});
await api.orchestration.dispatchCommand({
type: "thread.meta.update",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
branch: result.worktree.branch,
worktreePath: result.worktree.path,
});
Expand All@@ -1390,43 +1455,25 @@ export default function ChatView({ threadId }: ChatViewProps) {
rememberAsLastInvoked: false,
});
}
} catch (err) {
dispatch({
type: "SET_ERROR",
threadId: activeThread.id,
error: err instanceof Error ? err.message : "Failed to create worktree",
});
return;
}
}

// Auto-title from first message
if (activeThread.messages.length === 0) {
const titleSeed =
trimmed ||
(composerImagesSnapshot.length > 0
? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
: "New thread");
const title = truncateTitle(titleSeed);
if (api) {
// Auto-title from first message
if (isFirstMessage) {
const titleSeed =
trimmed ||
(composerImagesSnapshot.length > 0
? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
: "New thread");
const title = truncateTitle(titleSeed);
await api.orchestration.dispatchCommand({
type: "thread.meta.update",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
title,
});
}
}

setThreadError(activeThread.id, null);
promptRef.current = "";
setPrompt("");
setComposerImages([]);
setComposerCursor(0);
setComposerHighlightedItemId(null);

setIsSending(true);
try {
setSendPhase("sending-turn");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Send phase not set before first await

Medium Severity

For non-worktree first messages, the composer is cleared (lines 1350–1355) but setSendPhase is not called until line 1401, after the auto-title await. During this gap, sendPhase remains "idle", so isSendBusy is false. Because the textarea is now always enabled during sends (disabled={isConnecting}), a user could type a new message and submit it before setSendPhase("sending-turn") fires, bypassing the isSendBusy guard and causing concurrent turn dispatches.

Additional Locations (1)

Fix in CursorFix in Web

const turnAttachments = await Promise.all(
composerImagesSnapshot.map(
async (
Expand All@@ -1446,27 +1493,41 @@ export default function ChatView({ threadId }: ChatViewProps) {
}),
),
);
attemptedTurnStart = true;
await api.orchestration.dispatchCommand({
type: "thread.turn.start",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
message: {
messageId: newMessageId(),
messageId: messageIdForSend,
role: "user",
text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT,
attachments: turnAttachments,
},
model: selectedModel || undefined,
effort: selectedEffort || undefined,
createdAt: new Date().toISOString(),
createdAt: messageCreatedAt,
});
} catch (err) {
if (
!attemptedTurnStart &&
promptRef.current.length === 0 &&
composerImagesRef.current.length === 0
) {
setOptimisticUserMessages((existing) =>
existing.filter((message) => message.id !== messageIdForSend),
);
promptRef.current = trimmed;
setPrompt(trimmed);
setComposerImages(composerImagesSnapshot);
setComposerCursor(trimmed.length);
}
Comment thread
juliusmarminge marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optimistic message persists as ghost after failed dispatch

Medium Severity

When attemptedTurnStart is true but thread.turn.start dispatch fails (e.g., network error), the optimistic message is never removed from optimisticUserMessages. The catch block only cleans up when !attemptedTurnStart, and the cleanup effect at line 1045 early-returns when activeThread.messages.length === 0 (first-message case), so the ghost message persists in timelineMessages indefinitely until the user switches threads. For follow-up messages, the cleanup effect also can't remove it since the server never received the message ID. This results in a permanently visible "sent" message that was never actually delivered.

Additional Locations (1)

Fix in CursorFix in Web

setThreadError(
activeThread.id,
threadIdForSend,
err instanceof Error ? err.message : "Failed to send message.",
);
} finally {
setIsSending(false);
setSendPhase("idle");
}
};

Expand DownExpand Up@@ -1733,7 +1794,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
onScroll={onMessagesScroll}
>
<MessagesTimeline
hasMessages={activeThread.messages.length > 0}
hasMessages={timelineMessages.length > 0}
isWorking={isWorking}
scrollContainerRef={messagesScrollRef}
timelineEntries={timelineEntries}
Expand DownExpand Up@@ -1851,7 +1912,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
? "Ask for follow-up changes or attach images"
: "Ask anything, @tag files/folders, or use /model"
}
disabled={isSending || isConnecting}
disabled={isConnecting}
/>
</div>

Expand DownExpand Up@@ -1897,6 +1958,9 @@ export default function ChatView({ threadId }: ChatViewProps) {

{/* Right side: send / stop button */}
<div className="flex shrink-0 items-center gap-2">
{isPreparingWorktree ? (
<span className="text-muted-foreground/70 text-xs">Preparing worktree...</span>
) : null}
{phase === "running" ? (
<button
type="button"
Expand All@@ -1919,13 +1983,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
type="submit"
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"
disabled={
isSending || isConnecting || (!prompt.trim() && composerImages.length === 0)
isSendBusy || isConnecting || (!prompt.trim() && composerImages.length === 0)
}
aria-label={
isConnecting ? "Connecting" : isSending ? "Sending" : "Send message"
isConnecting
? "Connecting"
: isPreparingWorktree
? "Preparing worktree"
: isSendBusy
? "Sending"
: "Send message"
}
>
{isConnecting || isSending ? (
{isConnecting || isSendBusy ? (
<svg
width="14"
height="14"
Expand Down
5 changes: 3 additions & 2 deletions scripts/dev-runner.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -154,12 +154,13 @@ export function parseDevRunnerArgs(rawArgs) {
function resolveStateDir(env, envOverrides) {
const overrideValue = envOverrides?.T3CODE_STATE_DIR?.trim();
if (overrideValue) {
return overrideValue;
// Resolve relative paths against cwd (monorepo root) before turbo changes directories
return path.resolve(overrideValue);
}

const envValue = env?.T3CODE_STATE_DIR?.trim();
if (envValue) {
return envValue;
return path.resolve(envValue);
}

return DEFAULT_DEV_STATE_DIR;
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 128 additions & 58 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ import { truncateTitle } from "../truncateTitle";
import {
DEFAULT_THREAD_TERMINAL_ID,
MAX_THREAD_TERMINAL_COUNT,
type ChatMessage,
type ChatImageAttachment,
type TurnDiffSummary,
} from "../types";
Expand DownExpand Up@@ -188,6 +189,8 @@ type ComposerCommandItem =
description: string;
};

type SendPhase = "idle" | "preparing-worktree" | "sending-turn";

function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
Expand DownExpand Up@@ -342,8 +345,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
const [isDragOverComposer, setIsDragOverComposer] = useState(false);
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
const [isSending, setIsSending] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
const [isConnecting, _setIsConnecting] = useState(false);
const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);
const [envMode, setEnvMode] = useState<"local" | "worktree">("local");
Expand DownExpand Up@@ -397,7 +401,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
);
const phase = derivePhase(activeThread?.session ?? null);
const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint;
const isSendBusy = sendPhase !== "idle";
const isPreparingWorktree = sendPhase === "preparing-worktree";
const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
const nowIso = new Date(nowTick).toISOString();
const threadActivities = activeThread?.activities ?? [];
const workLogEntries = useMemo(
Expand All@@ -417,9 +423,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
() => derivePendingApprovals(threadActivities),
[threadActivities],
);
const timelineMessages = useMemo(() => {
const serverMessages = activeThread?.messages ?? [];
if (optimisticUserMessages.length === 0) {
return serverMessages;
}
const serverIds = new Set(serverMessages.map((message) => message.id));
const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id));
if (pendingMessages.length === 0) {
return serverMessages;
}
return [...serverMessages, ...pendingMessages];
}, [activeThread?.messages, optimisticUserMessages]);
const timelineEntries = useMemo(
() => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries),
[activeThread?.messages, workLogEntries],
() => deriveTimelineEntries(timelineMessages, workLogEntries),
[timelineMessages, workLogEntries],
);
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);
Expand DownExpand Up@@ -960,7 +978,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
}, [lastInvokedScriptByProjectId]);

// Auto-scroll on new messages
const messageCount = activeThread?.messages.length ?? 0;
const messageCount = timelineMessages.length;
const workLogCount = workLogEntries.length;
const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
const scrollContainer = messagesScrollRef.current;
Expand DownExpand Up@@ -1019,14 +1037,30 @@ export default function ChatView({ threadId }: ChatViewProps) {
composerImagesRef.current = composerImages;
}, [composerImages]);

useEffect(() => {
if (!activeThread?.id) {
setOptimisticUserMessages([]);
return;
}
if (activeThread.messages.length === 0) {
return;
}
const serverIds = new Set(activeThread.messages.map((message) => message.id));
setOptimisticUserMessages((existing) => {
const next = existing.filter((message) => !serverIds.has(message.id));
return next.length === existing.length ? existing : next;
});
}, [activeThread?.id, activeThread?.messages]);

useEffect(() => {
setComposerImages((existing) => {
revokePreviewUrls(existing);
return [];
});
setOptimisticUserMessages([]);
setPrompt("");
promptRef.current = "";
setIsSending(false);
setSendPhase("idle");
setComposerCursor(0);
setComposerHighlightedItemId(null);
dragDepthRef.current = 0;
Expand DownExpand Up@@ -1315,7 +1349,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
const api = readNativeApi();
if (!api || !activeThread || isRevertingCheckpoint) return;

if (phase === "running" || isSending || isConnecting) {
if (phase === "running" || isSendBusy || isConnecting) {
setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
return;
}
Expand DownExpand Up@@ -1349,36 +1383,67 @@ export default function ChatView({ threadId }: ChatViewProps) {
setIsRevertingCheckpoint(false);
}
},
[activeThread, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError],
[activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
);

const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => {
e.preventDefault();
const api = readNativeApi();
if (!api || !activeThread || isSending || isConnecting) return;
if (!api || !activeThread || isSendBusy || isConnecting) return;
const trimmed = prompt.trim();
if (!trimmed && composerImages.length === 0) return;
if (!activeProject) return;
const threadIdForSend = activeThread.id;
const isFirstMessage = activeThread.messages.length === 0;
const baseBranchForWorktree =
isFirstMessage && envMode === "worktree" && !activeThread.worktreePath
? activeThread.branch
: null;
const composerImagesSnapshot = [...composerImages];
const messageIdForSend = newMessageId();
const messageCreatedAt = new Date().toISOString();
const optimisticAttachments = composerImagesSnapshot.map((image) => ({
type: "image" as const,
id: image.id,
name: image.name,
mimeType: image.mimeType,
sizeBytes: image.sizeBytes,
previewUrl: image.previewUrl,
}));
setOptimisticUserMessages((existing) => [
...existing,
{
id: messageIdForSend,
role: "user",
text: trimmed,
...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}),
createdAt: messageCreatedAt,
streaming: false,
},
]);

// On first message: lock in branch + create worktree if needed.
if (
activeThread.messages.length === 0 &&
activeThread.branch &&
envMode === "worktree" &&
!activeThread.worktreePath
) {
try {
setThreadError(threadIdForSend, null);
promptRef.current = "";
setPrompt("");
setComposerImages([]);
setComposerCursor(0);
setComposerHighlightedItemId(null);

let attemptedTurnStart = false;
try {
// On first message: lock in branch + create worktree if needed.
if (baseBranchForWorktree) {
setSendPhase("preparing-worktree");
const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`;
const result = await createWorktreeMutation.mutateAsync({
cwd: activeProject.cwd,
branch: activeThread.branch,
branch: baseBranchForWorktree,
newBranch,
});
await api.orchestration.dispatchCommand({
type: "thread.meta.update",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
branch: result.worktree.branch,
worktreePath: result.worktree.path,
});
Expand All@@ -1390,43 +1455,25 @@ export default function ChatView({ threadId }: ChatViewProps) {
rememberAsLastInvoked: false,
});
}
} catch (err) {
dispatch({
type: "SET_ERROR",
threadId: activeThread.id,
error: err instanceof Error ? err.message : "Failed to create worktree",
});
return;
}
}

// Auto-title from first message
if (activeThread.messages.length === 0) {
const titleSeed =
trimmed ||
(composerImagesSnapshot.length > 0
? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
: "New thread");
const title = truncateTitle(titleSeed);
if (api) {
// Auto-title from first message
if (isFirstMessage) {
const titleSeed =
trimmed ||
(composerImagesSnapshot.length > 0
? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
: "New thread");
const title = truncateTitle(titleSeed);
await api.orchestration.dispatchCommand({
type: "thread.meta.update",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
title,
});
}
}

setThreadError(activeThread.id, null);
promptRef.current = "";
setPrompt("");
setComposerImages([]);
setComposerCursor(0);
setComposerHighlightedItemId(null);

setIsSending(true);
try {
setSendPhase("sending-turn");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Send phase not set before first await

Medium Severity

For non-worktree first messages, the composer is cleared (lines 1350–1355) but setSendPhase is not called until line 1401, after the auto-title await. During this gap, sendPhase remains "idle", so isSendBusy is false. Because the textarea is now always enabled during sends (disabled={isConnecting}), a user could type a new message and submit it before setSendPhase("sending-turn") fires, bypassing the isSendBusy guard and causing concurrent turn dispatches.

Additional Locations (1)

Fix in CursorFix in Web

const turnAttachments = await Promise.all(
composerImagesSnapshot.map(
async (
Expand All@@ -1446,27 +1493,41 @@ export default function ChatView({ threadId }: ChatViewProps) {
}),
),
);
attemptedTurnStart = true;
await api.orchestration.dispatchCommand({
type: "thread.turn.start",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
message: {
messageId: newMessageId(),
messageId: messageIdForSend,
role: "user",
text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT,
attachments: turnAttachments,
},
model: selectedModel || undefined,
effort: selectedEffort || undefined,
createdAt: new Date().toISOString(),
createdAt: messageCreatedAt,
});
} catch (err) {
if (
!attemptedTurnStart &&
promptRef.current.length === 0 &&
composerImagesRef.current.length === 0
) {
setOptimisticUserMessages((existing) =>
existing.filter((message) => message.id !== messageIdForSend),
);
promptRef.current = trimmed;
setPrompt(trimmed);
setComposerImages(composerImagesSnapshot);
setComposerCursor(trimmed.length);
}
Comment thread
juliusmarminge marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optimistic message persists as ghost after failed dispatch

Medium Severity

When attemptedTurnStart is true but thread.turn.start dispatch fails (e.g., network error), the optimistic message is never removed from optimisticUserMessages. The catch block only cleans up when !attemptedTurnStart, and the cleanup effect at line 1045 early-returns when activeThread.messages.length === 0 (first-message case), so the ghost message persists in timelineMessages indefinitely until the user switches threads. For follow-up messages, the cleanup effect also can't remove it since the server never received the message ID. This results in a permanently visible "sent" message that was never actually delivered.

Additional Locations (1)

Fix in CursorFix in Web

setThreadError(
activeThread.id,
threadIdForSend,
err instanceof Error ? err.message : "Failed to send message.",
);
} finally {
setIsSending(false);
setSendPhase("idle");
}
};

Expand DownExpand Up@@ -1733,7 +1794,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
onScroll={onMessagesScroll}
>
<MessagesTimeline
hasMessages={activeThread.messages.length > 0}
hasMessages={timelineMessages.length > 0}
isWorking={isWorking}
scrollContainerRef={messagesScrollRef}
timelineEntries={timelineEntries}
Expand DownExpand Up@@ -1851,7 +1912,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
? "Ask for follow-up changes or attach images"
: "Ask anything, @tag files/folders, or use /model"
}
disabled={isSending || isConnecting}
disabled={isConnecting}
/>
</div>

Expand DownExpand Up@@ -1897,6 +1958,9 @@ export default function ChatView({ threadId }: ChatViewProps) {

{/* Right side: send / stop button */}
<div className="flex shrink-0 items-center gap-2">
{isPreparingWorktree ? (
<span className="text-muted-foreground/70 text-xs">Preparing worktree...</span>
) : null}
{phase === "running" ? (
<button
type="button"
Expand All@@ -1919,13 +1983,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
type="submit"
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"
disabled={
isSending || isConnecting || (!prompt.trim() && composerImages.length === 0)
isSendBusy || isConnecting || (!prompt.trim() && composerImages.length === 0)
}
aria-label={
isConnecting ? "Connecting" : isSending ? "Sending" : "Send message"
isConnecting
? "Connecting"
: isPreparingWorktree
? "Preparing worktree"
: isSendBusy
? "Sending"
: "Send message"
}
>
{isConnecting || isSending ? (
{isConnecting || isSendBusy ? (
<svg
width="14"
height="14"
Expand Down
5 changes: 3 additions & 2 deletions scripts/dev-runner.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -154,12 +154,13 @@ export function parseDevRunnerArgs(rawArgs) {
function resolveStateDir(env, envOverrides) {
const overrideValue = envOverrides?.T3CODE_STATE_DIR?.trim();
if (overrideValue) {
return overrideValue;
// Resolve relative paths against cwd (monorepo root) before turbo changes directories
return path.resolve(overrideValue);
}

const envValue = env?.T3CODE_STATE_DIR?.trim();
if (envValue) {
return envValue;
return path.resolve(envValue);
}

return DEFAULT_DEV_STATE_DIR;
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 128 additions & 58 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ import { truncateTitle } from "../truncateTitle";
import {
DEFAULT_THREAD_TERMINAL_ID,
MAX_THREAD_TERMINAL_COUNT,
type ChatMessage,
type ChatImageAttachment,
type TurnDiffSummary,
} from "../types";
Expand DownExpand Up@@ -188,6 +189,8 @@ type ComposerCommandItem =
description: string;
};

type SendPhase = "idle" | "preparing-worktree" | "sending-turn";

function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
Expand DownExpand Up@@ -342,8 +345,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
const [isDragOverComposer, setIsDragOverComposer] = useState(false);
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
const [isSending, setIsSending] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
const [isConnecting, _setIsConnecting] = useState(false);
const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);
const [envMode, setEnvMode] = useState<"local" | "worktree">("local");
Expand DownExpand Up@@ -397,7 +401,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
);
const phase = derivePhase(activeThread?.session ?? null);
const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint;
const isSendBusy = sendPhase !== "idle";
const isPreparingWorktree = sendPhase === "preparing-worktree";
const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
const nowIso = new Date(nowTick).toISOString();
const threadActivities = activeThread?.activities ?? [];
const workLogEntries = useMemo(
Expand All@@ -417,9 +423,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
() => derivePendingApprovals(threadActivities),
[threadActivities],
);
const timelineMessages = useMemo(() => {
const serverMessages = activeThread?.messages ?? [];
if (optimisticUserMessages.length === 0) {
return serverMessages;
}
const serverIds = new Set(serverMessages.map((message) => message.id));
const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id));
if (pendingMessages.length === 0) {
return serverMessages;
}
return [...serverMessages, ...pendingMessages];
}, [activeThread?.messages, optimisticUserMessages]);
const timelineEntries = useMemo(
() => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries),
[activeThread?.messages, workLogEntries],
() => deriveTimelineEntries(timelineMessages, workLogEntries),
[timelineMessages, workLogEntries],
);
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);
Expand DownExpand Up@@ -960,7 +978,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
}, [lastInvokedScriptByProjectId]);

// Auto-scroll on new messages
const messageCount = activeThread?.messages.length ?? 0;
const messageCount = timelineMessages.length;
const workLogCount = workLogEntries.length;
const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
const scrollContainer = messagesScrollRef.current;
Expand DownExpand Up@@ -1019,14 +1037,30 @@ export default function ChatView({ threadId }: ChatViewProps) {
composerImagesRef.current = composerImages;
}, [composerImages]);

useEffect(() => {
if (!activeThread?.id) {
setOptimisticUserMessages([]);
return;
}
if (activeThread.messages.length === 0) {
return;
}
const serverIds = new Set(activeThread.messages.map((message) => message.id));
setOptimisticUserMessages((existing) => {
const next = existing.filter((message) => !serverIds.has(message.id));
return next.length === existing.length ? existing : next;
});
}, [activeThread?.id, activeThread?.messages]);

useEffect(() => {
setComposerImages((existing) => {
revokePreviewUrls(existing);
return [];
});
setOptimisticUserMessages([]);
setPrompt("");
promptRef.current = "";
setIsSending(false);
setSendPhase("idle");
setComposerCursor(0);
setComposerHighlightedItemId(null);
dragDepthRef.current = 0;
Expand DownExpand Up@@ -1315,7 +1349,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
const api = readNativeApi();
if (!api || !activeThread || isRevertingCheckpoint) return;

if (phase === "running" || isSending || isConnecting) {
if (phase === "running" || isSendBusy || isConnecting) {
setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
return;
}
Expand DownExpand Up@@ -1349,36 +1383,67 @@ export default function ChatView({ threadId }: ChatViewProps) {
setIsRevertingCheckpoint(false);
}
},
[activeThread, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError],
[activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
);

const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => {
e.preventDefault();
const api = readNativeApi();
if (!api || !activeThread || isSending || isConnecting) return;
if (!api || !activeThread || isSendBusy || isConnecting) return;
const trimmed = prompt.trim();
if (!trimmed && composerImages.length === 0) return;
if (!activeProject) return;
const threadIdForSend = activeThread.id;
const isFirstMessage = activeThread.messages.length === 0;
const baseBranchForWorktree =
isFirstMessage && envMode === "worktree" && !activeThread.worktreePath
? activeThread.branch
: null;
const composerImagesSnapshot = [...composerImages];
const messageIdForSend = newMessageId();
const messageCreatedAt = new Date().toISOString();
const optimisticAttachments = composerImagesSnapshot.map((image) => ({
type: "image" as const,
id: image.id,
name: image.name,
mimeType: image.mimeType,
sizeBytes: image.sizeBytes,
previewUrl: image.previewUrl,
}));
setOptimisticUserMessages((existing) => [
...existing,
{
id: messageIdForSend,
role: "user",
text: trimmed,
...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}),
createdAt: messageCreatedAt,
streaming: false,
},
]);

// On first message: lock in branch + create worktree if needed.
if (
activeThread.messages.length === 0 &&
activeThread.branch &&
envMode === "worktree" &&
!activeThread.worktreePath
) {
try {
setThreadError(threadIdForSend, null);
promptRef.current = "";
setPrompt("");
setComposerImages([]);
setComposerCursor(0);
setComposerHighlightedItemId(null);

let attemptedTurnStart = false;
try {
// On first message: lock in branch + create worktree if needed.
if (baseBranchForWorktree) {
setSendPhase("preparing-worktree");
const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`;
const result = await createWorktreeMutation.mutateAsync({
cwd: activeProject.cwd,
branch: activeThread.branch,
branch: baseBranchForWorktree,
newBranch,
});
await api.orchestration.dispatchCommand({
type: "thread.meta.update",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
branch: result.worktree.branch,
worktreePath: result.worktree.path,
});
Expand All@@ -1390,43 +1455,25 @@ export default function ChatView({ threadId }: ChatViewProps) {
rememberAsLastInvoked: false,
});
}
} catch (err) {
dispatch({
type: "SET_ERROR",
threadId: activeThread.id,
error: err instanceof Error ? err.message : "Failed to create worktree",
});
return;
}
}

// Auto-title from first message
if (activeThread.messages.length === 0) {
const titleSeed =
trimmed ||
(composerImagesSnapshot.length > 0
? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
: "New thread");
const title = truncateTitle(titleSeed);
if (api) {
// Auto-title from first message
if (isFirstMessage) {
const titleSeed =
trimmed ||
(composerImagesSnapshot.length > 0
? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
: "New thread");
const title = truncateTitle(titleSeed);
await api.orchestration.dispatchCommand({
type: "thread.meta.update",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
title,
});
}
}

setThreadError(activeThread.id, null);
promptRef.current = "";
setPrompt("");
setComposerImages([]);
setComposerCursor(0);
setComposerHighlightedItemId(null);

setIsSending(true);
try {
setSendPhase("sending-turn");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Send phase not set before first await

Medium Severity

For non-worktree first messages, the composer is cleared (lines 1350–1355) but setSendPhase is not called until line 1401, after the auto-title await. During this gap, sendPhase remains "idle", so isSendBusy is false. Because the textarea is now always enabled during sends (disabled={isConnecting}), a user could type a new message and submit it before setSendPhase("sending-turn") fires, bypassing the isSendBusy guard and causing concurrent turn dispatches.

Additional Locations (1)

Fix in CursorFix in Web

const turnAttachments = await Promise.all(
composerImagesSnapshot.map(
async (
Expand All@@ -1446,27 +1493,41 @@ export default function ChatView({ threadId }: ChatViewProps) {
}),
),
);
attemptedTurnStart = true;
await api.orchestration.dispatchCommand({
type: "thread.turn.start",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
message: {
messageId: newMessageId(),
messageId: messageIdForSend,
role: "user",
text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT,
attachments: turnAttachments,
},
model: selectedModel || undefined,
effort: selectedEffort || undefined,
createdAt: new Date().toISOString(),
createdAt: messageCreatedAt,
});
} catch (err) {
if (
!attemptedTurnStart &&
promptRef.current.length === 0 &&
composerImagesRef.current.length === 0
) {
setOptimisticUserMessages((existing) =>
existing.filter((message) => message.id !== messageIdForSend),
);
promptRef.current = trimmed;
setPrompt(trimmed);
setComposerImages(composerImagesSnapshot);
setComposerCursor(trimmed.length);
}
Comment thread
juliusmarminge marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optimistic message persists as ghost after failed dispatch

Medium Severity

When attemptedTurnStart is true but thread.turn.start dispatch fails (e.g., network error), the optimistic message is never removed from optimisticUserMessages. The catch block only cleans up when !attemptedTurnStart, and the cleanup effect at line 1045 early-returns when activeThread.messages.length === 0 (first-message case), so the ghost message persists in timelineMessages indefinitely until the user switches threads. For follow-up messages, the cleanup effect also can't remove it since the server never received the message ID. This results in a permanently visible "sent" message that was never actually delivered.

Additional Locations (1)

Fix in CursorFix in Web

setThreadError(
activeThread.id,
threadIdForSend,
err instanceof Error ? err.message : "Failed to send message.",
);
} finally {
setIsSending(false);
setSendPhase("idle");
}
};

Expand DownExpand Up@@ -1733,7 +1794,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
onScroll={onMessagesScroll}
>
<MessagesTimeline
hasMessages={activeThread.messages.length > 0}
hasMessages={timelineMessages.length > 0}
isWorking={isWorking}
scrollContainerRef={messagesScrollRef}
timelineEntries={timelineEntries}
Expand DownExpand Up@@ -1851,7 +1912,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
? "Ask for follow-up changes or attach images"
: "Ask anything, @tag files/folders, or use /model"
}
disabled={isSending || isConnecting}
disabled={isConnecting}
/>
</div>

Expand DownExpand Up@@ -1897,6 +1958,9 @@ export default function ChatView({ threadId }: ChatViewProps) {

{/* Right side: send / stop button */}
<div className="flex shrink-0 items-center gap-2">
{isPreparingWorktree ? (
<span className="text-muted-foreground/70 text-xs">Preparing worktree...</span>
) : null}
{phase === "running" ? (
<button
type="button"
Expand All@@ -1919,13 +1983,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
type="submit"
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"
disabled={
isSending || isConnecting || (!prompt.trim() && composerImages.length === 0)
isSendBusy || isConnecting || (!prompt.trim() && composerImages.length === 0)
}
aria-label={
isConnecting ? "Connecting" : isSending ? "Sending" : "Send message"
isConnecting
? "Connecting"
: isPreparingWorktree
? "Preparing worktree"
: isSendBusy
? "Sending"
: "Send message"
}
>
{isConnecting || isSending ? (
{isConnecting || isSendBusy ? (
<svg
width="14"
height="14"
Expand Down
5 changes: 3 additions & 2 deletions scripts/dev-runner.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -154,12 +154,13 @@ export function parseDevRunnerArgs(rawArgs) {
function resolveStateDir(env, envOverrides) {
const overrideValue = envOverrides?.T3CODE_STATE_DIR?.trim();
if (overrideValue) {
return overrideValue;
// Resolve relative paths against cwd (monorepo root) before turbo changes directories
return path.resolve(overrideValue);
}

const envValue = env?.T3CODE_STATE_DIR?.trim();
if (envValue) {
return envValue;
return path.resolve(envValue);
}

return DEFAULT_DEV_STATE_DIR;
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 128 additions & 58 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ import { truncateTitle } from "../truncateTitle";
import {
DEFAULT_THREAD_TERMINAL_ID,
MAX_THREAD_TERMINAL_COUNT,
type ChatMessage,
type ChatImageAttachment,
type TurnDiffSummary,
} from "../types";
Expand DownExpand Up@@ -188,6 +189,8 @@ type ComposerCommandItem =
description: string;
};

type SendPhase = "idle" | "preparing-worktree" | "sending-turn";

function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
Expand DownExpand Up@@ -342,8 +345,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
const [isDragOverComposer, setIsDragOverComposer] = useState(false);
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
const [isSending, setIsSending] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
const [isConnecting, _setIsConnecting] = useState(false);
const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);
const [envMode, setEnvMode] = useState<"local" | "worktree">("local");
Expand DownExpand Up@@ -397,7 +401,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
);
const phase = derivePhase(activeThread?.session ?? null);
const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint;
const isSendBusy = sendPhase !== "idle";
const isPreparingWorktree = sendPhase === "preparing-worktree";
const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
const nowIso = new Date(nowTick).toISOString();
const threadActivities = activeThread?.activities ?? [];
const workLogEntries = useMemo(
Expand All@@ -417,9 +423,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
() => derivePendingApprovals(threadActivities),
[threadActivities],
);
const timelineMessages = useMemo(() => {
const serverMessages = activeThread?.messages ?? [];
if (optimisticUserMessages.length === 0) {
return serverMessages;
}
const serverIds = new Set(serverMessages.map((message) => message.id));
const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id));
if (pendingMessages.length === 0) {
return serverMessages;
}
return [...serverMessages, ...pendingMessages];
}, [activeThread?.messages, optimisticUserMessages]);
const timelineEntries = useMemo(
() => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries),
[activeThread?.messages, workLogEntries],
() => deriveTimelineEntries(timelineMessages, workLogEntries),
[timelineMessages, workLogEntries],
);
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);
Expand DownExpand Up@@ -960,7 +978,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
}, [lastInvokedScriptByProjectId]);

// Auto-scroll on new messages
const messageCount = activeThread?.messages.length ?? 0;
const messageCount = timelineMessages.length;
const workLogCount = workLogEntries.length;
const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
const scrollContainer = messagesScrollRef.current;
Expand DownExpand Up@@ -1019,14 +1037,30 @@ export default function ChatView({ threadId }: ChatViewProps) {
composerImagesRef.current = composerImages;
}, [composerImages]);

useEffect(() => {
if (!activeThread?.id) {
setOptimisticUserMessages([]);
return;
}
if (activeThread.messages.length === 0) {
return;
}
const serverIds = new Set(activeThread.messages.map((message) => message.id));
setOptimisticUserMessages((existing) => {
const next = existing.filter((message) => !serverIds.has(message.id));
return next.length === existing.length ? existing : next;
});
}, [activeThread?.id, activeThread?.messages]);

useEffect(() => {
setComposerImages((existing) => {
revokePreviewUrls(existing);
return [];
});
setOptimisticUserMessages([]);
setPrompt("");
promptRef.current = "";
setIsSending(false);
setSendPhase("idle");
setComposerCursor(0);
setComposerHighlightedItemId(null);
dragDepthRef.current = 0;
Expand DownExpand Up@@ -1315,7 +1349,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
const api = readNativeApi();
if (!api || !activeThread || isRevertingCheckpoint) return;

if (phase === "running" || isSending || isConnecting) {
if (phase === "running" || isSendBusy || isConnecting) {
setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
return;
}
Expand DownExpand Up@@ -1349,36 +1383,67 @@ export default function ChatView({ threadId }: ChatViewProps) {
setIsRevertingCheckpoint(false);
}
},
[activeThread, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError],
[activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
);

const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => {
e.preventDefault();
const api = readNativeApi();
if (!api || !activeThread || isSending || isConnecting) return;
if (!api || !activeThread || isSendBusy || isConnecting) return;
const trimmed = prompt.trim();
if (!trimmed && composerImages.length === 0) return;
if (!activeProject) return;
const threadIdForSend = activeThread.id;
const isFirstMessage = activeThread.messages.length === 0;
const baseBranchForWorktree =
isFirstMessage && envMode === "worktree" && !activeThread.worktreePath
? activeThread.branch
: null;
const composerImagesSnapshot = [...composerImages];
const messageIdForSend = newMessageId();
const messageCreatedAt = new Date().toISOString();
const optimisticAttachments = composerImagesSnapshot.map((image) => ({
type: "image" as const,
id: image.id,
name: image.name,
mimeType: image.mimeType,
sizeBytes: image.sizeBytes,
previewUrl: image.previewUrl,
}));
setOptimisticUserMessages((existing) => [
...existing,
{
id: messageIdForSend,
role: "user",
text: trimmed,
...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}),
createdAt: messageCreatedAt,
streaming: false,
},
]);

// On first message: lock in branch + create worktree if needed.
if (
activeThread.messages.length === 0 &&
activeThread.branch &&
envMode === "worktree" &&
!activeThread.worktreePath
) {
try {
setThreadError(threadIdForSend, null);
promptRef.current = "";
setPrompt("");
setComposerImages([]);
setComposerCursor(0);
setComposerHighlightedItemId(null);

let attemptedTurnStart = false;
try {
// On first message: lock in branch + create worktree if needed.
if (baseBranchForWorktree) {
setSendPhase("preparing-worktree");
const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`;
const result = await createWorktreeMutation.mutateAsync({
cwd: activeProject.cwd,
branch: activeThread.branch,
branch: baseBranchForWorktree,
newBranch,
});
await api.orchestration.dispatchCommand({
type: "thread.meta.update",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
branch: result.worktree.branch,
worktreePath: result.worktree.path,
});
Expand All@@ -1390,43 +1455,25 @@ export default function ChatView({ threadId }: ChatViewProps) {
rememberAsLastInvoked: false,
});
}
} catch (err) {
dispatch({
type: "SET_ERROR",
threadId: activeThread.id,
error: err instanceof Error ? err.message : "Failed to create worktree",
});
return;
}
}

// Auto-title from first message
if (activeThread.messages.length === 0) {
const titleSeed =
trimmed ||
(composerImagesSnapshot.length > 0
? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
: "New thread");
const title = truncateTitle(titleSeed);
if (api) {
// Auto-title from first message
if (isFirstMessage) {
const titleSeed =
trimmed ||
(composerImagesSnapshot.length > 0
? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
: "New thread");
const title = truncateTitle(titleSeed);
await api.orchestration.dispatchCommand({
type: "thread.meta.update",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
title,
});
}
}

setThreadError(activeThread.id, null);
promptRef.current = "";
setPrompt("");
setComposerImages([]);
setComposerCursor(0);
setComposerHighlightedItemId(null);

setIsSending(true);
try {
setSendPhase("sending-turn");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Send phase not set before first await

Medium Severity

For non-worktree first messages, the composer is cleared (lines 1350–1355) but setSendPhase is not called until line 1401, after the auto-title await. During this gap, sendPhase remains "idle", so isSendBusy is false. Because the textarea is now always enabled during sends (disabled={isConnecting}), a user could type a new message and submit it before setSendPhase("sending-turn") fires, bypassing the isSendBusy guard and causing concurrent turn dispatches.

Additional Locations (1)

Fix in CursorFix in Web

const turnAttachments = await Promise.all(
composerImagesSnapshot.map(
async (
Expand All@@ -1446,27 +1493,41 @@ export default function ChatView({ threadId }: ChatViewProps) {
}),
),
);
attemptedTurnStart = true;
await api.orchestration.dispatchCommand({
type: "thread.turn.start",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
message: {
messageId: newMessageId(),
messageId: messageIdForSend,
role: "user",
text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT,
attachments: turnAttachments,
},
model: selectedModel || undefined,
effort: selectedEffort || undefined,
createdAt: new Date().toISOString(),
createdAt: messageCreatedAt,
});
} catch (err) {
if (
!attemptedTurnStart &&
promptRef.current.length === 0 &&
composerImagesRef.current.length === 0
) {
setOptimisticUserMessages((existing) =>
existing.filter((message) => message.id !== messageIdForSend),
);
promptRef.current = trimmed;
setPrompt(trimmed);
setComposerImages(composerImagesSnapshot);
setComposerCursor(trimmed.length);
}
Comment thread
juliusmarminge marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optimistic message persists as ghost after failed dispatch

Medium Severity

When attemptedTurnStart is true but thread.turn.start dispatch fails (e.g., network error), the optimistic message is never removed from optimisticUserMessages. The catch block only cleans up when !attemptedTurnStart, and the cleanup effect at line 1045 early-returns when activeThread.messages.length === 0 (first-message case), so the ghost message persists in timelineMessages indefinitely until the user switches threads. For follow-up messages, the cleanup effect also can't remove it since the server never received the message ID. This results in a permanently visible "sent" message that was never actually delivered.

Additional Locations (1)

Fix in CursorFix in Web

setThreadError(
activeThread.id,
threadIdForSend,
err instanceof Error ? err.message : "Failed to send message.",
);
} finally {
setIsSending(false);
setSendPhase("idle");
}
};

Expand DownExpand Up@@ -1733,7 +1794,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
onScroll={onMessagesScroll}
>
<MessagesTimeline
hasMessages={activeThread.messages.length > 0}
hasMessages={timelineMessages.length > 0}
isWorking={isWorking}
scrollContainerRef={messagesScrollRef}
timelineEntries={timelineEntries}
Expand DownExpand Up@@ -1851,7 +1912,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
? "Ask for follow-up changes or attach images"
: "Ask anything, @tag files/folders, or use /model"
}
disabled={isSending || isConnecting}
disabled={isConnecting}
/>
</div>

Expand DownExpand Up@@ -1897,6 +1958,9 @@ export default function ChatView({ threadId }: ChatViewProps) {

{/* Right side: send / stop button */}
<div className="flex shrink-0 items-center gap-2">
{isPreparingWorktree ? (
<span className="text-muted-foreground/70 text-xs">Preparing worktree...</span>
) : null}
{phase === "running" ? (
<button
type="button"
Expand All@@ -1919,13 +1983,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
type="submit"
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"
disabled={
isSending || isConnecting || (!prompt.trim() && composerImages.length === 0)
isSendBusy || isConnecting || (!prompt.trim() && composerImages.length === 0)
}
aria-label={
isConnecting ? "Connecting" : isSending ? "Sending" : "Send message"
isConnecting
? "Connecting"
: isPreparingWorktree
? "Preparing worktree"
: isSendBusy
? "Sending"
: "Send message"
}
>
{isConnecting || isSending ? (
{isConnecting || isSendBusy ? (
<svg
width="14"
height="14"
Expand Down
5 changes: 3 additions & 2 deletions scripts/dev-runner.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -154,12 +154,13 @@ export function parseDevRunnerArgs(rawArgs) {
function resolveStateDir(env, envOverrides) {
const overrideValue = envOverrides?.T3CODE_STATE_DIR?.trim();
if (overrideValue) {
return overrideValue;
// Resolve relative paths against cwd (monorepo root) before turbo changes directories
return path.resolve(overrideValue);
}

const envValue = env?.T3CODE_STATE_DIR?.trim();
if (envValue) {
return envValue;
return path.resolve(envValue);
}

return DEFAULT_DEV_STATE_DIR;
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 128 additions & 58 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ import { truncateTitle } from "../truncateTitle";
import {
DEFAULT_THREAD_TERMINAL_ID,
MAX_THREAD_TERMINAL_COUNT,
type ChatMessage,
type ChatImageAttachment,
type TurnDiffSummary,
} from "../types";
Expand DownExpand Up@@ -188,6 +189,8 @@ type ComposerCommandItem =
description: string;
};

type SendPhase = "idle" | "preparing-worktree" | "sending-turn";

function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
Expand DownExpand Up@@ -342,8 +345,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
const [isDragOverComposer, setIsDragOverComposer] = useState(false);
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
const [isSending, setIsSending] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
const [isConnecting, _setIsConnecting] = useState(false);
const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);
const [envMode, setEnvMode] = useState<"local" | "worktree">("local");
Expand DownExpand Up@@ -397,7 +401,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
);
const phase = derivePhase(activeThread?.session ?? null);
const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint;
const isSendBusy = sendPhase !== "idle";
const isPreparingWorktree = sendPhase === "preparing-worktree";
const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
const nowIso = new Date(nowTick).toISOString();
const threadActivities = activeThread?.activities ?? [];
const workLogEntries = useMemo(
Expand All@@ -417,9 +423,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
() => derivePendingApprovals(threadActivities),
[threadActivities],
);
const timelineMessages = useMemo(() => {
const serverMessages = activeThread?.messages ?? [];
if (optimisticUserMessages.length === 0) {
return serverMessages;
}
const serverIds = new Set(serverMessages.map((message) => message.id));
const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id));
if (pendingMessages.length === 0) {
return serverMessages;
}
return [...serverMessages, ...pendingMessages];
}, [activeThread?.messages, optimisticUserMessages]);
const timelineEntries = useMemo(
() => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries),
[activeThread?.messages, workLogEntries],
() => deriveTimelineEntries(timelineMessages, workLogEntries),
[timelineMessages, workLogEntries],
);
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);
Expand DownExpand Up@@ -960,7 +978,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
}, [lastInvokedScriptByProjectId]);

// Auto-scroll on new messages
const messageCount = activeThread?.messages.length ?? 0;
const messageCount = timelineMessages.length;
const workLogCount = workLogEntries.length;
const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
const scrollContainer = messagesScrollRef.current;
Expand DownExpand Up@@ -1019,14 +1037,30 @@ export default function ChatView({ threadId }: ChatViewProps) {
composerImagesRef.current = composerImages;
}, [composerImages]);

useEffect(() => {
if (!activeThread?.id) {
setOptimisticUserMessages([]);
return;
}
if (activeThread.messages.length === 0) {
return;
}
const serverIds = new Set(activeThread.messages.map((message) => message.id));
setOptimisticUserMessages((existing) => {
const next = existing.filter((message) => !serverIds.has(message.id));
return next.length === existing.length ? existing : next;
});
}, [activeThread?.id, activeThread?.messages]);

useEffect(() => {
setComposerImages((existing) => {
revokePreviewUrls(existing);
return [];
});
setOptimisticUserMessages([]);
setPrompt("");
promptRef.current = "";
setIsSending(false);
setSendPhase("idle");
setComposerCursor(0);
setComposerHighlightedItemId(null);
dragDepthRef.current = 0;
Expand DownExpand Up@@ -1315,7 +1349,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
const api = readNativeApi();
if (!api || !activeThread || isRevertingCheckpoint) return;

if (phase === "running" || isSending || isConnecting) {
if (phase === "running" || isSendBusy || isConnecting) {
setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
return;
}
Expand DownExpand Up@@ -1349,36 +1383,67 @@ export default function ChatView({ threadId }: ChatViewProps) {
setIsRevertingCheckpoint(false);
}
},
[activeThread, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError],
[activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
);

const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => {
e.preventDefault();
const api = readNativeApi();
if (!api || !activeThread || isSending || isConnecting) return;
if (!api || !activeThread || isSendBusy || isConnecting) return;
const trimmed = prompt.trim();
if (!trimmed && composerImages.length === 0) return;
if (!activeProject) return;
const threadIdForSend = activeThread.id;
const isFirstMessage = activeThread.messages.length === 0;
const baseBranchForWorktree =
isFirstMessage && envMode === "worktree" && !activeThread.worktreePath
? activeThread.branch
: null;
const composerImagesSnapshot = [...composerImages];
const messageIdForSend = newMessageId();
const messageCreatedAt = new Date().toISOString();
const optimisticAttachments = composerImagesSnapshot.map((image) => ({
type: "image" as const,
id: image.id,
name: image.name,
mimeType: image.mimeType,
sizeBytes: image.sizeBytes,
previewUrl: image.previewUrl,
}));
setOptimisticUserMessages((existing) => [
...existing,
{
id: messageIdForSend,
role: "user",
text: trimmed,
...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}),
createdAt: messageCreatedAt,
streaming: false,
},
]);

// On first message: lock in branch + create worktree if needed.
if (
activeThread.messages.length === 0 &&
activeThread.branch &&
envMode === "worktree" &&
!activeThread.worktreePath
) {
try {
setThreadError(threadIdForSend, null);
promptRef.current = "";
setPrompt("");
setComposerImages([]);
setComposerCursor(0);
setComposerHighlightedItemId(null);

let attemptedTurnStart = false;
try {
// On first message: lock in branch + create worktree if needed.
if (baseBranchForWorktree) {
setSendPhase("preparing-worktree");
const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`;
const result = await createWorktreeMutation.mutateAsync({
cwd: activeProject.cwd,
branch: activeThread.branch,
branch: baseBranchForWorktree,
newBranch,
});
await api.orchestration.dispatchCommand({
type: "thread.meta.update",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
branch: result.worktree.branch,
worktreePath: result.worktree.path,
});
Expand All@@ -1390,43 +1455,25 @@ export default function ChatView({ threadId }: ChatViewProps) {
rememberAsLastInvoked: false,
});
}
} catch (err) {
dispatch({
type: "SET_ERROR",
threadId: activeThread.id,
error: err instanceof Error ? err.message : "Failed to create worktree",
});
return;
}
}

// Auto-title from first message
if (activeThread.messages.length === 0) {
const titleSeed =
trimmed ||
(composerImagesSnapshot.length > 0
? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
: "New thread");
const title = truncateTitle(titleSeed);
if (api) {
// Auto-title from first message
if (isFirstMessage) {
const titleSeed =
trimmed ||
(composerImagesSnapshot.length > 0
? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
: "New thread");
const title = truncateTitle(titleSeed);
await api.orchestration.dispatchCommand({
type: "thread.meta.update",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
title,
});
}
}

setThreadError(activeThread.id, null);
promptRef.current = "";
setPrompt("");
setComposerImages([]);
setComposerCursor(0);
setComposerHighlightedItemId(null);

setIsSending(true);
try {
setSendPhase("sending-turn");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Send phase not set before first await

Medium Severity

For non-worktree first messages, the composer is cleared (lines 1350–1355) but setSendPhase is not called until line 1401, after the auto-title await. During this gap, sendPhase remains "idle", so isSendBusy is false. Because the textarea is now always enabled during sends (disabled={isConnecting}), a user could type a new message and submit it before setSendPhase("sending-turn") fires, bypassing the isSendBusy guard and causing concurrent turn dispatches.

Additional Locations (1)

Fix in CursorFix in Web

const turnAttachments = await Promise.all(
composerImagesSnapshot.map(
async (
Expand All@@ -1446,27 +1493,41 @@ export default function ChatView({ threadId }: ChatViewProps) {
}),
),
);
attemptedTurnStart = true;
await api.orchestration.dispatchCommand({
type: "thread.turn.start",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
message: {
messageId: newMessageId(),
messageId: messageIdForSend,
role: "user",
text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT,
attachments: turnAttachments,
},
model: selectedModel || undefined,
effort: selectedEffort || undefined,
createdAt: new Date().toISOString(),
createdAt: messageCreatedAt,
});
} catch (err) {
if (
!attemptedTurnStart &&
promptRef.current.length === 0 &&
composerImagesRef.current.length === 0
) {
setOptimisticUserMessages((existing) =>
existing.filter((message) => message.id !== messageIdForSend),
);
promptRef.current = trimmed;
setPrompt(trimmed);
setComposerImages(composerImagesSnapshot);
setComposerCursor(trimmed.length);
}
Comment thread
juliusmarminge marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optimistic message persists as ghost after failed dispatch

Medium Severity

When attemptedTurnStart is true but thread.turn.start dispatch fails (e.g., network error), the optimistic message is never removed from optimisticUserMessages. The catch block only cleans up when !attemptedTurnStart, and the cleanup effect at line 1045 early-returns when activeThread.messages.length === 0 (first-message case), so the ghost message persists in timelineMessages indefinitely until the user switches threads. For follow-up messages, the cleanup effect also can't remove it since the server never received the message ID. This results in a permanently visible "sent" message that was never actually delivered.

Additional Locations (1)

Fix in CursorFix in Web

setThreadError(
activeThread.id,
threadIdForSend,
err instanceof Error ? err.message : "Failed to send message.",
);
} finally {
setIsSending(false);
setSendPhase("idle");
}
};

Expand DownExpand Up@@ -1733,7 +1794,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
onScroll={onMessagesScroll}
>
<MessagesTimeline
hasMessages={activeThread.messages.length > 0}
hasMessages={timelineMessages.length > 0}
isWorking={isWorking}
scrollContainerRef={messagesScrollRef}
timelineEntries={timelineEntries}
Expand DownExpand Up@@ -1851,7 +1912,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
? "Ask for follow-up changes or attach images"
: "Ask anything, @tag files/folders, or use /model"
}
disabled={isSending || isConnecting}
disabled={isConnecting}
/>
</div>

Expand DownExpand Up@@ -1897,6 +1958,9 @@ export default function ChatView({ threadId }: ChatViewProps) {

{/* Right side: send / stop button */}
<div className="flex shrink-0 items-center gap-2">
{isPreparingWorktree ? (
<span className="text-muted-foreground/70 text-xs">Preparing worktree...</span>
) : null}
{phase === "running" ? (
<button
type="button"
Expand All@@ -1919,13 +1983,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
type="submit"
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"
disabled={
isSending || isConnecting || (!prompt.trim() && composerImages.length === 0)
isSendBusy || isConnecting || (!prompt.trim() && composerImages.length === 0)
}
aria-label={
isConnecting ? "Connecting" : isSending ? "Sending" : "Send message"
isConnecting
? "Connecting"
: isPreparingWorktree
? "Preparing worktree"
: isSendBusy
? "Sending"
: "Send message"
}
>
{isConnecting || isSending ? (
{isConnecting || isSendBusy ? (
<svg
width="14"
height="14"
Expand Down
5 changes: 3 additions & 2 deletions scripts/dev-runner.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -154,12 +154,13 @@ export function parseDevRunnerArgs(rawArgs) {
function resolveStateDir(env, envOverrides) {
const overrideValue = envOverrides?.T3CODE_STATE_DIR?.trim();
if (overrideValue) {
return overrideValue;
// Resolve relative paths against cwd (monorepo root) before turbo changes directories
return path.resolve(overrideValue);
}

const envValue = env?.T3CODE_STATE_DIR?.trim();
if (envValue) {
return envValue;
return path.resolve(envValue);
}

return DEFAULT_DEV_STATE_DIR;
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 128 additions & 58 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ import { truncateTitle } from "../truncateTitle";
import {
DEFAULT_THREAD_TERMINAL_ID,
MAX_THREAD_TERMINAL_COUNT,
type ChatMessage,
type ChatImageAttachment,
type TurnDiffSummary,
} from "../types";
Expand DownExpand Up@@ -188,6 +189,8 @@ type ComposerCommandItem =
description: string;
};

type SendPhase = "idle" | "preparing-worktree" | "sending-turn";

function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
Expand DownExpand Up@@ -342,8 +345,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
const [isDragOverComposer, setIsDragOverComposer] = useState(false);
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
const [isSending, setIsSending] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
const [isConnecting, _setIsConnecting] = useState(false);
const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);
const [envMode, setEnvMode] = useState<"local" | "worktree">("local");
Expand DownExpand Up@@ -397,7 +401,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
);
const phase = derivePhase(activeThread?.session ?? null);
const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint;
const isSendBusy = sendPhase !== "idle";
const isPreparingWorktree = sendPhase === "preparing-worktree";
const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
const nowIso = new Date(nowTick).toISOString();
const threadActivities = activeThread?.activities ?? [];
const workLogEntries = useMemo(
Expand All@@ -417,9 +423,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
() => derivePendingApprovals(threadActivities),
[threadActivities],
);
const timelineMessages = useMemo(() => {
const serverMessages = activeThread?.messages ?? [];
if (optimisticUserMessages.length === 0) {
return serverMessages;
}
const serverIds = new Set(serverMessages.map((message) => message.id));
const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id));
if (pendingMessages.length === 0) {
return serverMessages;
}
return [...serverMessages, ...pendingMessages];
}, [activeThread?.messages, optimisticUserMessages]);
const timelineEntries = useMemo(
() => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries),
[activeThread?.messages, workLogEntries],
() => deriveTimelineEntries(timelineMessages, workLogEntries),
[timelineMessages, workLogEntries],
);
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);
Expand DownExpand Up@@ -960,7 +978,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
}, [lastInvokedScriptByProjectId]);

// Auto-scroll on new messages
const messageCount = activeThread?.messages.length ?? 0;
const messageCount = timelineMessages.length;
const workLogCount = workLogEntries.length;
const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
const scrollContainer = messagesScrollRef.current;
Expand DownExpand Up@@ -1019,14 +1037,30 @@ export default function ChatView({ threadId }: ChatViewProps) {
composerImagesRef.current = composerImages;
}, [composerImages]);

useEffect(() => {
if (!activeThread?.id) {
setOptimisticUserMessages([]);
return;
}
if (activeThread.messages.length === 0) {
return;
}
const serverIds = new Set(activeThread.messages.map((message) => message.id));
setOptimisticUserMessages((existing) => {
const next = existing.filter((message) => !serverIds.has(message.id));
return next.length === existing.length ? existing : next;
});
}, [activeThread?.id, activeThread?.messages]);

useEffect(() => {
setComposerImages((existing) => {
revokePreviewUrls(existing);
return [];
});
setOptimisticUserMessages([]);
setPrompt("");
promptRef.current = "";
setIsSending(false);
setSendPhase("idle");
setComposerCursor(0);
setComposerHighlightedItemId(null);
dragDepthRef.current = 0;
Expand DownExpand Up@@ -1315,7 +1349,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
const api = readNativeApi();
if (!api || !activeThread || isRevertingCheckpoint) return;

if (phase === "running" || isSending || isConnecting) {
if (phase === "running" || isSendBusy || isConnecting) {
setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
return;
}
Expand DownExpand Up@@ -1349,36 +1383,67 @@ export default function ChatView({ threadId }: ChatViewProps) {
setIsRevertingCheckpoint(false);
}
},
[activeThread, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError],
[activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
);

const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => {
e.preventDefault();
const api = readNativeApi();
if (!api || !activeThread || isSending || isConnecting) return;
if (!api || !activeThread || isSendBusy || isConnecting) return;
const trimmed = prompt.trim();
if (!trimmed && composerImages.length === 0) return;
if (!activeProject) return;
const threadIdForSend = activeThread.id;
const isFirstMessage = activeThread.messages.length === 0;
const baseBranchForWorktree =
isFirstMessage && envMode === "worktree" && !activeThread.worktreePath
? activeThread.branch
: null;
const composerImagesSnapshot = [...composerImages];
const messageIdForSend = newMessageId();
const messageCreatedAt = new Date().toISOString();
const optimisticAttachments = composerImagesSnapshot.map((image) => ({
type: "image" as const,
id: image.id,
name: image.name,
mimeType: image.mimeType,
sizeBytes: image.sizeBytes,
previewUrl: image.previewUrl,
}));
setOptimisticUserMessages((existing) => [
...existing,
{
id: messageIdForSend,
role: "user",
text: trimmed,
...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}),
createdAt: messageCreatedAt,
streaming: false,
},
]);

// On first message: lock in branch + create worktree if needed.
if (
activeThread.messages.length === 0 &&
activeThread.branch &&
envMode === "worktree" &&
!activeThread.worktreePath
) {
try {
setThreadError(threadIdForSend, null);
promptRef.current = "";
setPrompt("");
setComposerImages([]);
setComposerCursor(0);
setComposerHighlightedItemId(null);

let attemptedTurnStart = false;
try {
// On first message: lock in branch + create worktree if needed.
if (baseBranchForWorktree) {
setSendPhase("preparing-worktree");
const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`;
const result = await createWorktreeMutation.mutateAsync({
cwd: activeProject.cwd,
branch: activeThread.branch,
branch: baseBranchForWorktree,
newBranch,
});
await api.orchestration.dispatchCommand({
type: "thread.meta.update",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
branch: result.worktree.branch,
worktreePath: result.worktree.path,
});
Expand All@@ -1390,43 +1455,25 @@ export default function ChatView({ threadId }: ChatViewProps) {
rememberAsLastInvoked: false,
});
}
} catch (err) {
dispatch({
type: "SET_ERROR",
threadId: activeThread.id,
error: err instanceof Error ? err.message : "Failed to create worktree",
});
return;
}
}

// Auto-title from first message
if (activeThread.messages.length === 0) {
const titleSeed =
trimmed ||
(composerImagesSnapshot.length > 0
? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
: "New thread");
const title = truncateTitle(titleSeed);
if (api) {
// Auto-title from first message
if (isFirstMessage) {
const titleSeed =
trimmed ||
(composerImagesSnapshot.length > 0
? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
: "New thread");
const title = truncateTitle(titleSeed);
await api.orchestration.dispatchCommand({
type: "thread.meta.update",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
title,
});
}
}

setThreadError(activeThread.id, null);
promptRef.current = "";
setPrompt("");
setComposerImages([]);
setComposerCursor(0);
setComposerHighlightedItemId(null);

setIsSending(true);
try {
setSendPhase("sending-turn");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Send phase not set before first await

Medium Severity

For non-worktree first messages, the composer is cleared (lines 1350–1355) but setSendPhase is not called until line 1401, after the auto-title await. During this gap, sendPhase remains "idle", so isSendBusy is false. Because the textarea is now always enabled during sends (disabled={isConnecting}), a user could type a new message and submit it before setSendPhase("sending-turn") fires, bypassing the isSendBusy guard and causing concurrent turn dispatches.

Additional Locations (1)

Fix in CursorFix in Web

const turnAttachments = await Promise.all(
composerImagesSnapshot.map(
async (
Expand All@@ -1446,27 +1493,41 @@ export default function ChatView({ threadId }: ChatViewProps) {
}),
),
);
attemptedTurnStart = true;
await api.orchestration.dispatchCommand({
type: "thread.turn.start",
commandId: newCommandId(),
threadId: activeThread.id,
threadId: threadIdForSend,
message: {
messageId: newMessageId(),
messageId: messageIdForSend,
role: "user",
text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT,
attachments: turnAttachments,
},
model: selectedModel || undefined,
effort: selectedEffort || undefined,
createdAt: new Date().toISOString(),
createdAt: messageCreatedAt,
});
} catch (err) {
if (
!attemptedTurnStart &&
promptRef.current.length === 0 &&
composerImagesRef.current.length === 0
) {
setOptimisticUserMessages((existing) =>
existing.filter((message) => message.id !== messageIdForSend),
);
promptRef.current = trimmed;
setPrompt(trimmed);
setComposerImages(composerImagesSnapshot);
setComposerCursor(trimmed.length);
}
Comment thread
juliusmarminge marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optimistic message persists as ghost after failed dispatch

Medium Severity

When attemptedTurnStart is true but thread.turn.start dispatch fails (e.g., network error), the optimistic message is never removed from optimisticUserMessages. The catch block only cleans up when !attemptedTurnStart, and the cleanup effect at line 1045 early-returns when activeThread.messages.length === 0 (first-message case), so the ghost message persists in timelineMessages indefinitely until the user switches threads. For follow-up messages, the cleanup effect also can't remove it since the server never received the message ID. This results in a permanently visible "sent" message that was never actually delivered.

Additional Locations (1)

Fix in CursorFix in Web

setThreadError(
activeThread.id,
threadIdForSend,
err instanceof Error ? err.message : "Failed to send message.",
);
} finally {
setIsSending(false);
setSendPhase("idle");
}
};

Expand DownExpand Up@@ -1733,7 +1794,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
onScroll={onMessagesScroll}
>
<MessagesTimeline
hasMessages={activeThread.messages.length > 0}
hasMessages={timelineMessages.length > 0}
isWorking={isWorking}
scrollContainerRef={messagesScrollRef}
timelineEntries={timelineEntries}
Expand DownExpand Up@@ -1851,7 +1912,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
? "Ask for follow-up changes or attach images"
: "Ask anything, @tag files/folders, or use /model"
}
disabled={isSending || isConnecting}
disabled={isConnecting}
/>
</div>

Expand DownExpand Up@@ -1897,6 +1958,9 @@ export default function ChatView({ threadId }: ChatViewProps) {

{/* Right side: send / stop button */}
<div className="flex shrink-0 items-center gap-2">
{isPreparingWorktree ? (
<span className="text-muted-foreground/70 text-xs">Preparing worktree...</span>
) : null}
{phase === "running" ? (
<button
type="button"
Expand All@@ -1919,13 +1983,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
type="submit"
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"
disabled={
isSending || isConnecting || (!prompt.trim() && composerImages.length === 0)
isSendBusy || isConnecting || (!prompt.trim() && composerImages.length === 0)
}
aria-label={
isConnecting ? "Connecting" : isSending ? "Sending" : "Send message"
isConnecting
? "Connecting"
: isPreparingWorktree
? "Preparing worktree"
: isSendBusy
? "Sending"
: "Send message"
}
>
{isConnecting || isSending ? (
{isConnecting || isSendBusy ? (
<svg
width="14"
height="14"
Expand Down
5 changes: 3 additions & 2 deletions scripts/dev-runner.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -154,12 +154,13 @@ export function parseDevRunnerArgs(rawArgs) {
function resolveStateDir(env, envOverrides) {
const overrideValue = envOverrides?.T3CODE_STATE_DIR?.trim();
if (overrideValue) {
return overrideValue;
// Resolve relative paths against cwd (monorepo root) before turbo changes directories
return path.resolve(overrideValue);
}

const envValue = env?.T3CODE_STATE_DIR?.trim();
if (envValue) {
return envValue;
return path.resolve(envValue);
}

return DEFAULT_DEV_STATE_DIR;
Expand Down