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
163 changes: 136 additions & 27 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ import {
type TerminalContextSelection,
} from "../lib/terminalContext";
import { shouldUseCompactComposerFooter } from "./composerFooterLayout";
import { useMediaQuery } from "../hooks/useMediaQuery";
import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
Expand Down Expand Up @@ -336,6 +337,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
const [planSidebarOpen, setPlanSidebarOpen] = useState(false);
const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);
const [isComposerFocused, setIsComposerFocused] = useState(false);
const isMobileViewport = useMediaQuery("max-sm");
const isComposerCollapsedMobile = isMobileViewport && !isComposerFocused;
// Tracks whether the user explicitly dismissed the sidebar for the active turn.
const planSidebarDismissedForTurnRef = useRef<string | null>(null);
// When set, the thread-change reset effect will open the sidebar instead of closing it.
Expand Down Expand Up @@ -375,6 +379,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
const pendingInteractionAnchorFrameRef = useRef<number | null>(null);
const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
const composerFormRef = useRef<HTMLFormElement>(null);
const composerSurfaceRef = useRef<HTMLDivElement>(null);
const composerFormHeightRef = useRef(0);
const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
const composerSelectLockRef = useRef(false);
Expand All @@ -384,6 +389,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef<Record<string, number>>({});
const sendInFlightRef = useRef(false);
const composerBlurFrameRef = useRef<number | null>(null);
const dragDepthRef = useRef(0);
const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => {
Expand Down Expand Up @@ -1193,6 +1199,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
focusComposer();
});
}, [focusComposer]);
const scheduleComposerCollapseCheck = useCallback(() => {
if (composerBlurFrameRef.current !== null) {
window.cancelAnimationFrame(composerBlurFrameRef.current);
}
composerBlurFrameRef.current = window.requestAnimationFrame(() => {
composerBlurFrameRef.current = null;
const composerSurface = composerSurfaceRef.current;
const activeElement = document.activeElement;
if (
composerSurface &&
activeElement instanceof Node &&
composerSurface.contains(activeElement)
) {
return;
}
setIsComposerFocused(false);
});
}, []);
const addTerminalContextToDraft = useCallback(
(selection: TerminalContextSelection) => {
if (!activeThread) {
Expand Down Expand Up @@ -1854,6 +1878,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
);
}, [composerMenuItems, composerMenuOpen]);

useEffect(() => {
return () => {
if (composerBlurFrameRef.current !== null) {
window.cancelAnimationFrame(composerBlurFrameRef.current);
}
};
}, []);

useEffect(() => {
setIsRevertingCheckpoint(false);
}, [activeThread?.id]);
Expand Down Expand Up @@ -3581,42 +3613,114 @@ export default function ChatView({ threadId }: ChatViewProps) {
onDrop={onComposerDrop}
>
<div
ref={composerSurfaceRef}
className={cn(
"rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring/45",
isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border",
composerProviderState.composerSurfaceClassName,
)}
onFocusCapture={() => {
if (composerBlurFrameRef.current !== null) {
window.cancelAnimationFrame(composerBlurFrameRef.current);
composerBlurFrameRef.current = null;
}
setIsComposerFocused(true);
}}
onBlurCapture={() => {
scheduleComposerCollapseCheck();
}}
onClick={() => {
if (isComposerCollapsedMobile) {
// First expand the composer, then focus the editor after it renders
setIsComposerFocused(true);
requestAnimationFrame(() => {
composerEditorRef.current?.focusAtEnd();
});
}
}}
>
{activePendingApproval ? (
<div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
<ComposerPendingApprovalPanel
approval={activePendingApproval}
pendingCount={pendingApprovals.length}
/>
</div>
) : pendingUserInputs.length > 0 ? (
<div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
<ComposerPendingUserInputPanel
pendingUserInputs={pendingUserInputs}
respondingRequestIds={respondingRequestIds}
answers={activePendingDraftAnswers}
questionIndex={activePendingQuestionIndex}
onSelectOption={onSelectActivePendingUserInputOption}
onAdvance={onAdvanceActivePendingUserInput}
/>
</div>
) : showPlanFollowUpPrompt && activeProposedPlan ? (
<div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
<ComposerPlanFollowUpBanner
key={activeProposedPlan.id}
planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
/>
{!isComposerCollapsedMobile &&
(activePendingApproval ? (
<div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
<ComposerPendingApprovalPanel
approval={activePendingApproval}
pendingCount={pendingApprovals.length}
/>
</div>
) : pendingUserInputs.length > 0 ? (
<div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
<ComposerPendingUserInputPanel
pendingUserInputs={pendingUserInputs}
respondingRequestIds={respondingRequestIds}
answers={activePendingDraftAnswers}
questionIndex={activePendingQuestionIndex}
onSelectOption={onSelectActivePendingUserInputOption}
onAdvance={onAdvanceActivePendingUserInput}
/>
</div>
) : showPlanFollowUpPrompt && activeProposedPlan ? (
<div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
<ComposerPlanFollowUpBanner
key={activeProposedPlan.id}
planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
/>
</div>
) : null)}
{isComposerCollapsedMobile && (
<div className="flex items-center justify-between gap-2 px-3 py-2">
<span
className={cn(
"min-w-0 truncate text-[14px]",
(
activePendingProgress
? activePendingProgress.customAnswer
: prompt.trim()
)
? "text-foreground"
: "text-muted-foreground/35",
)}
>
{activePendingProgress
? activePendingProgress.customAnswer ||
"Type your own answer, or leave this blank to use the selected option"
: prompt.trim() || "Ask anything..."}
</span>
<button
type="button"
className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/90 text-primary-foreground disabled:opacity-30"
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 Medium components/ChatView.tsx:3650

When showPlanFollowUpPrompt is true with an empty prompt, the collapsed mobile send button is incorrectly disabled because !composerSendState.hasSendableContent is true. However, in the expanded view at lines 4015-4047, an empty prompt with showPlanFollowUpPrompt correctly shows an enabled 'Implement' button. The collapsed view's disabled logic doesn't account for this valid state where sending without prompt content is permitted.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/components/ChatView.tsx around line 3650:

When `showPlanFollowUpPrompt` is true with an empty prompt, the collapsed mobile send button is incorrectly disabled because `!composerSendState.hasSendableContent` is `true`. However, in the expanded view at lines 4015-4047, an empty prompt with `showPlanFollowUpPrompt` correctly shows an enabled 'Implement' button. The collapsed view's disabled logic doesn't account for this valid state where sending without prompt content is permitted.

Evidence trail:
- Collapsed mobile send button disabled logic at line 3652: apps/web/src/components/ChatView.tsx (view_range [3640, 3670])
- Expanded view 'Implement' button with showPlanFollowUpPrompt=true and empty prompt at lines 4015-4022: apps/web/src/components/ChatView.tsx (view_range [3995, 4025])
- Expanded view regular send button at line 4049: apps/web/src/components/ChatView.tsx (view_range [4043, 4070])
- grep for 'hasSendableContent' showing only 4 usages, confirming collapsed view doesn't have special handling

disabled={
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 Medium components/ChatView.tsx:3691

In mobile collapsed mode, the send button remains visible and enabled even when isComposerApprovalState is true, allowing messages to be sent while an approval request is pending. The expanded mode correctly replaces the send button with approval actions, but the collapsed mobile UI at line 3693 doesn't check for this state. Consider disabling the send button when isComposerApprovalState is true to prevent submissions during pending approvals.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/components/ChatView.tsx around line 3691:

In mobile collapsed mode, the send button remains visible and enabled even when `isComposerApprovalState` is true, allowing messages to be sent while an approval request is pending. The expanded mode correctly replaces the send button with approval actions, but the collapsed mobile UI at line 3693 doesn't check for this state. Consider disabling the send button when `isComposerApprovalState` is true to prevent submissions during pending approvals.

Evidence trail:
ChatView.tsx line 342: `const isComposerCollapsedMobile = isMobileViewport && !isComposerFocused;`
ChatView.tsx lines 3691-3692: Collapsed mobile send button's disabled condition: `isSendBusy || isConnecting || !composerSendState.hasSendableContent`
ChatView.tsx line 755: `const isComposerApprovalState = activePendingApproval !== null;`
ChatView.tsx line 3841: Expanded mode composer input disabled with: `disabled={isConnecting || isComposerApprovalState}`
ChatView.tsx lines 3846-3854: Expanded mode shows `ComposerPendingApprovalActions` instead of send button when `activePendingApproval` exists
ChatView.logic.ts lines 123-143: `deriveComposerSendState` does not consider approval state

isSendBusy || isConnecting || !composerSendState.hasSendableContent
}
aria-label="Send message"
onPointerDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
onSend();
}}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path
d="M8 3L8 13M8 3L4 7M8 3L12 7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
) : null}
)}
<div
className={cn(
"relative px-3 pb-2 sm:px-4",
hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4",
isComposerCollapsedMobile && "hidden",
)}
>
{composerMenuOpen && !isComposerApprovalState && (
Expand All @@ -3633,7 +3737,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
</div>
)}

{!isComposerApprovalState &&
{!isComposerCollapsedMobile &&
!isComposerApprovalState &&
pendingUserInputs.length === 0 &&
composerImages.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
Expand Down Expand Up @@ -3738,7 +3843,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
</div>

{/* Bottom toolbar */}
{activePendingApproval ? (
{isComposerCollapsedMobile ? null : activePendingApproval ? (
<div className="flex items-center justify-end gap-2 px-2.5 pb-2.5 sm:px-3 sm:pb-3">
<ComposerPendingApprovalActions
requestId={activePendingApproval.requestId}
Expand Down Expand Up @@ -3916,6 +4021,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
type="submit"
size="sm"
className="rounded-full px-4"
onPointerDown={(e) => e.preventDefault()}
disabled={
activePendingIsResponding ||
(activePendingProgress.isLastQuestion
Expand Down Expand Up @@ -3954,6 +4060,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
type="submit"
size="sm"
className="h-9 rounded-full px-4 sm:h-8"
onPointerDown={(e) => e.preventDefault()}
disabled={isSendBusy || isConnecting}
>
{isConnecting || isSendBusy ? "Sending..." : "Refine"}
Expand All @@ -3964,6 +4071,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
type="submit"
size="sm"
className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8"
onPointerDown={(e) => e.preventDefault()}
disabled={isSendBusy || isConnecting}
>
{isConnecting || isSendBusy ? "Sending..." : "Implement"}
Expand Down Expand Up @@ -3997,6 +4105,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
<button
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"
onPointerDown={(e) => e.preventDefault()}
disabled={
isSendBusy || isConnecting || !composerSendState.hasSendableContent
}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1093,7 +1093,7 @@ function ComposerPromptEditorInner({
contentEditable={
<ContentEditable
className={cn(
"block max-h-[200px] min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent text-[14px] leading-relaxed text-foreground focus:outline-none",
"block max-h-[200px] min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent text-[16px] leading-relaxed text-foreground focus:outline-none sm:text-[14px]",
className,
)}
data-testid="composer-editor"
Expand All @@ -1104,7 +1104,7 @@ function ComposerPromptEditorInner({
}
placeholder={
terminalContexts.length > 0 ? null : (
<div className="pointer-events-none absolute inset-0 text-[14px] leading-relaxed text-muted-foreground/35">
<div className="pointer-events-none absolute inset-0 text-[16px] leading-relaxed text-muted-foreground/35 sm:text-[14px]">
{placeholder}
</div>
)
Expand Down