Skip to content
Open
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
119 changes: 76 additions & 43 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ import {
type ComposerImageAttachment,
type DraftThreadEnvMode,
type PersistedComposerImageAttachment,
flushComposerDraftStorage,
useComposerDraftStore,
useComposerThreadDraft,
} from "../composerDraftStore";
Expand All @@ -141,6 +142,7 @@ import { CodexTraitsPicker } from "./chat/CodexTraitsPicker";
import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu";
import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel";
import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel";
import { ComposerPendingDiffComments } from "./chat/ComposerPendingDiffComments";
import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner";
import { ProviderHealthBanner } from "./chat/ProviderHealthBanner";
import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
Expand All @@ -159,6 +161,7 @@ import {
SendPhase,
} from "./ChatView.logic";
import { useLocalStorage } from "~/hooks/useLocalStorage";
import { appendDiffContextCommentsToPrompt } from "../lib/diffContextComments";

const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000;
const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
Expand All @@ -174,6 +177,17 @@ const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
const SCRIPT_TERMINAL_COLS = 120;
const SCRIPT_TERMINAL_ROWS = 30;

function extendReplacementRangeForTrailingSpace(
text: string,
rangeEnd: number,
replacement: string,
): number {
if (!replacement.endsWith(" ")) {
return rangeEnd;
}
return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd;
}

interface ChatViewProps {
threadId: ThreadId;
}
Expand Down Expand Up @@ -218,6 +232,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
(store) => store.syncPersistedAttachments,
);
const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);
const restoreComposerDraftSendContent = useComposerDraftStore(
(store) => store.restoreComposerSendContent,
);
const clearComposerDraftDiffContextComments = useComposerDraftStore(
(store) => store.clearDiffContextComments,
);
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
const getDraftThreadByProjectId = useComposerDraftStore(
(store) => store.getDraftThreadByProjectId,
Expand Down Expand Up @@ -361,6 +381,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
[draftThread, fallbackDraftProject?.model, localDraftError, threadId],
);
const activeThread = serverThread ?? localDraftThread;
const pendingDiffContextComments = composerDraft.diffContextComments;
const runtimeMode =
composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
const interactionMode =
Expand Down Expand Up @@ -558,6 +579,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
const isSendBusy = sendPhase !== "idle";
const isPreparingWorktree = sendPhase === "preparing-worktree";
const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
const hasPendingDiffContextComments = pendingDiffContextComments.length > 0;
const nowIso = new Date(nowTick).toISOString();
const activeWorkStartedAt = deriveActiveWorkStartedAt(
activeLatestTurn,
Expand Down Expand Up @@ -2219,9 +2241,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
setComposerTrigger(null);
return;
}
if (!trimmed && composerImages.length === 0) return;
if (!trimmed && composerImages.length === 0 && !hasPendingDiffContextComments) return;
if (!activeProject) return;
const threadIdForSend = activeThread.id;
const pendingDiffContextCommentsSnapshot = [...pendingDiffContextComments];
const persistedComposerAttachmentsSnapshot = [...composerDraft.persistedAttachments];
const messageTextForSend = appendDiffContextCommentsToPrompt(
trimmed,
pendingDiffContextComments,
);
const isFirstMessage = !isServerThread || activeThread.messages.length === 0;
const baseBranchForWorktree =
isFirstMessage && envMode === "worktree" && !activeThread.worktreePath
Expand Down Expand Up @@ -2268,7 +2296,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
{
id: messageIdForSend,
role: "user",
text: trimmed,
text: messageTextForSend,
...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}),
createdAt: messageCreatedAt,
streaming: false,
Expand All @@ -2281,6 +2309,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
setThreadError(threadIdForSend, null);
promptRef.current = "";
clearComposerDraftContent(threadIdForSend);
flushComposerDraftStorage();
setComposerHighlightedItemId(null);
setComposerCursor(0);
setComposerTrigger(null);
Expand Down Expand Up @@ -2406,7 +2435,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
message: {
messageId: messageIdForSend,
role: "user",
text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT,
text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT,
attachments: turnAttachments,
},
model: selectedModel || undefined,
Expand Down Expand Up @@ -2434,7 +2463,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
if (
!turnStartSucceeded &&
promptRef.current.length === 0 &&
composerImagesRef.current.length === 0
composerImagesRef.current.length === 0 &&
(useComposerDraftStore.getState().draftsByThreadId[threadIdForSend]?.diffContextComments
.length ?? 0) === 0
) {
setOptimisticUserMessages((existing) => {
const removed = existing.filter((message) => message.id === messageIdForSend);
Expand All @@ -2445,9 +2476,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
return next.length === existing.length ? existing : next;
});
promptRef.current = trimmed;
restoreComposerDraftSendContent(threadIdForSend, {
prompt: trimmed,
images: composerImagesSnapshot.map(cloneComposerImageForRetry),
persistedAttachments: persistedComposerAttachmentsSnapshot,
diffContextComments: pendingDiffContextCommentsSnapshot,
});
flushComposerDraftStorage();
setPrompt(trimmed);
setComposerCursor(collapseExpandedComposerCursor(trimmed, trimmed.length));
addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry));
setComposerTrigger(detectComposerTrigger(trimmed, trimmed.length));
}
setThreadError(
Expand Down Expand Up @@ -2968,17 +3005,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
};
}, [composerCursor]);

const extendReplacementRangeForTrailingSpace = (
text: string,
rangeEnd: number,
replacement: string,
): number => {
if (!replacement.endsWith(" ")) {
return rangeEnd;
}
return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd;
};

const resolveActiveComposerTrigger = useCallback((): {
snapshot: { value: string; cursor: number; expandedCursor: number };
trigger: ComposerTrigger | null;
Expand Down Expand Up @@ -3367,46 +3393,48 @@ export default function ChatView({ threadId }: ChatViewProps) {

{!isComposerApprovalState &&
pendingUserInputs.length === 0 &&
composerImages.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
(pendingDiffContextComments.length > 0 || composerImages.length > 0) && (
<div className="mb-2 flex flex-wrap items-center gap-2">
<ComposerPendingDiffComments
comments={pendingDiffContextComments}
onClearAll={() => clearComposerDraftDiffContextComments(threadId)}
/>
{composerImages.map((image) => (
<div
<button
key={image.id}
className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background"
type="button"
className="inline-flex h-8 cursor-zoom-in items-center gap-1.5 rounded-full border border-border/70 bg-card/90 pl-1 pr-1.5 text-foreground shadow-xs transition-colors hover:bg-accent/50"
aria-label={`Preview ${image.name}`}
onClick={() => {
const preview = buildExpandedImagePreview(composerImages, image.id);
if (!preview) return;
setExpandedImage(preview);
}}
>
{image.previewUrl ? (
<button
type="button"
className="h-full w-full cursor-zoom-in"
aria-label={`Preview ${image.name}`}
onClick={() => {
const preview = buildExpandedImagePreview(
composerImages,
image.id,
);
if (!preview) return;
setExpandedImage(preview);
}}
>
<span className="size-6 shrink-0 overflow-hidden rounded-full">
<img
src={image.previewUrl}
alt={image.name}
className="h-full w-full object-cover"
/>
</button>
</span>
) : (
<div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70">
{image.name}
</div>
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] text-muted-foreground">
IMG
</span>
)}
<span className="max-w-24 truncate text-xs font-medium">
{image.name}
</span>
{nonPersistedComposerImageIdSet.has(image.id) && (
<Tooltip>
<TooltipTrigger
render={
<span
role="img"
aria-label="Draft attachment may not persist"
className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600"
className="inline-flex items-center justify-center text-amber-600"
>
<CircleAlertIcon className="size-3" />
</span>
Expand All @@ -3424,13 +3452,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
<Button
variant="ghost"
size="icon-xs"
className="absolute right-1 top-1 bg-background/80 hover:bg-background/90"
onClick={() => removeComposerImage(image.id)}
className="size-5 rounded-full"
onClick={(e) => {
e.stopPropagation();
removeComposerImage(image.id);
}}
aria-label={`Remove ${image.name}`}
>
<XIcon />
<XIcon className="size-3" />
</Button>
</div>
</button>
))}
</div>
)}
Expand Down Expand Up @@ -3723,7 +3754,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
disabled={
isSendBusy ||
isConnecting ||
(!prompt.trim() && composerImages.length === 0)
(!prompt.trim() &&
composerImages.length === 0 &&
!hasPendingDiffContextComments)
}
aria-label={
isConnecting
Expand Down
100 changes: 100 additions & 0 deletions apps/web/src/components/DiffContextCommentDraft.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useEffect, useRef } from "react";
import { Button } from "./ui/button";

interface DiffContextCommentDraftProps {
filePath: string;
lineStart: number;
lineEnd: number;
body: string;
error: string;
onBodyChange: (value: string) => void;
onCancel: () => void;
onSubmit: () => void;
onDelete?: () => void;
submitLabel?: string;
}

function formatLineRange(start: number, end: number): string {
return start === end ? `${start}` : `${start}-${end}`;
}

export const DIFF_CONTEXT_COMMENT_CARD_STYLE = {
width: "min(44rem, calc(100cqw - 3.5rem), calc(100vw - 7.5rem))",
maxWidth: "100%",
} as const;

export function DiffContextCommentDraft({
filePath,
lineStart,
lineEnd,
body,
error,
onBodyChange,
onCancel,
onSubmit,
onDelete,
submitLabel = "Comment",
}: DiffContextCommentDraftProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);

useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) {
return;
}

textarea.focus();
const cursorPosition = textarea.value.length;
textarea.setSelectionRange(cursorPosition, cursorPosition);
}, [filePath, lineStart, lineEnd]);

return (
<div
className="ml-2 mr-5 my-1 min-w-0"
style={DIFF_CONTEXT_COMMENT_CARD_STYLE}
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="group rounded-md border border-border bg-card transition-colors duration-200 focus-within:border-ring/45">
<div className="relative">
<textarea
ref={textareaRef}
value={body}
onChange={(event) => onBodyChange(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Escape") {
return;
}

event.preventDefault();
event.stopPropagation();
onCancel();
}}
placeholder="Request change"
aria-label={`Comment on ${filePath}:${formatLineRange(lineStart, lineEnd)}`}
className="min-h-[200px] w-full resize-none bg-transparent px-3 py-3 pb-15 text-sm text-foreground outline-none placeholder:text-muted-foreground/70 sm:px-4 sm:py-4"
/>
<div className="absolute right-4 bottom-3 left-4 z-10 flex flex-wrap items-end justify-between gap-3 sm:right-5 sm:left-5">
<div className="min-w-0 flex-1 basis-40">
{error ? <span className="text-xs text-destructive">{error}</span> : null}
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
{onDelete ? (
<Button type="button" variant="destructive" onClick={onDelete}>
Delete
</Button>
) : null}
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="button" onClick={onSubmit}>
{submitLabel}
</Button>
</div>
</div>
</div>
</div>
</div>
);
}
Loading
Loading