Skip to content
Closed
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
14 changes: 13 additions & 1 deletion apps/web/src/components/ComposerPromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1490,10 +1490,22 @@ function ComposerPromptEditorInner({
const rootElement = editor.getRootElement();
if (!rootElement) return;
const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor);
rootElement.focus();
const scrollX = window.scrollX;
const scrollY = window.scrollY;
const restoreScroll = () => {
if (window.scrollX !== scrollX || window.scrollY !== scrollY) {
window.scrollTo(scrollX, scrollY);
}
};
rootElement.focus({ preventScroll: true });
editor.update(() => {
$setSelectionAtComposerOffset(boundedCursor);
});
restoreScroll();
requestAnimationFrame(() => {
restoreScroll();
requestAnimationFrame(restoreScroll);
});
snapshotRef.current = {
value: snapshotRef.current.value,
cursor: boundedCursor,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/NoActiveThreadState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { cn } from "~/lib/utils";

export function NoActiveThreadState() {
return (
<SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
<SidebarInset className="h-app-viewport safe-area-top-x min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
<header
className={cn(
Expand Down
13 changes: 8 additions & 5 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1949,7 +1949,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
<SidebarMenuButton
ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined}
size="sm"
className={`gap-2 px-2 py-1.5 text-left hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground ${
className={`min-w-0 gap-2 px-2 py-1.5 pr-8 text-left hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground ${
isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"
}`}
{...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})}
Expand Down Expand Up @@ -1982,8 +1982,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
/>
)}
<ProjectFavicon environmentId={project.environmentId} cwd={project.cwd} />
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-xs font-medium text-foreground/90">
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<span
className="min-w-0 truncate text-xs font-medium text-foreground/90"
title={project.displayName}
>
{project.displayName}
</span>
{project.groupedProjectCount > 1 ? (
Expand All @@ -2006,7 +2009,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
? "Remote project"
: "Available in multiple environments"
}
className="pointer-events-none absolute top-1 right-1.5 inline-flex size-5 items-center justify-center rounded-md text-muted-foreground/50 transition-opacity duration-150 group-hover/project-header:opacity-0 group-focus-within/project-header:opacity-0"
className="pointer-events-none absolute top-1 right-1.5 inline-flex size-5 items-center justify-center rounded-md text-muted-foreground/50 transition-opacity duration-150 pointer-coarse:opacity-0 group-hover/project-header:opacity-0 group-focus-within/project-header:opacity-0"
/>
}
>
Expand All @@ -2020,7 +2023,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
<Tooltip>
<TooltipTrigger
render={
<div className="pointer-events-none absolute top-1 right-1.5 opacity-0 transition-opacity duration-150 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100">
<div className="pointer-events-none absolute top-1 right-1.5 opacity-0 transition-opacity duration-150 pointer-coarse:pointer-events-auto pointer-coarse:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100">
<button
type="button"
aria-label={`Create new thread in ${project.displayName}`}
Expand Down
177 changes: 147 additions & 30 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
useRef,
useState,
} from "react";
import { flushSync } from "react-dom";
import { useQuery } from "@tanstack/react-query";
import { useDebouncedValue } from "@tanstack/react-pacer";
import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
Expand Down Expand Up @@ -111,6 +112,7 @@ import type { PendingApproval, PendingUserInput } from "../../session-logic";
import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";
import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";
import { searchProviderSkills } from "../../providerSkillSearch";
import { useIsTouchDevice, useMediaQuery } from "../../hooks/useMediaQuery";

const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;

Expand Down Expand Up @@ -537,6 +539,18 @@ export const ChatComposer = memo(
onExpandImage,
} = props;

// ------------------------------------------------------------------
// Touch device detection (for Enter-to-send vs. Enter-to-newline)
// ------------------------------------------------------------------
const isTouchDevice = useIsTouchDevice();

// ------------------------------------------------------------------
// Mobile collapsed composer state
// ------------------------------------------------------------------
const [isComposerFocused, setIsComposerFocused] = useState(false);
const isMobileViewport = useMediaQuery("max-sm");
const isComposerCollapsedMobile = isMobileViewport && !isComposerFocused;

// ------------------------------------------------------------------
// Store subscriptions (prompt / images / terminal contexts)
// ------------------------------------------------------------------
Expand Down Expand Up @@ -780,7 +794,9 @@ export const ChatComposer = memo(
// ------------------------------------------------------------------
const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
const composerFormRef = useRef<HTMLFormElement>(null);
const composerSurfaceRef = useRef<HTMLDivElement>(null);
const composerFormHeightRef = useRef(0);
const composerBlurFrameRef = useRef<number | null>(null);
const composerSelectLockRef = useRef(false);
const composerMenuOpenRef = useRef(false);
const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);
Expand Down Expand Up @@ -1579,9 +1595,15 @@ export const ChatComposer = memo(
return true;
}
}
if (key === "Enter" && !event.shiftKey) {
void onSend();
return true;
if (key === "Enter") {
// On touch devices, bare Enter inserts a newline;
// Cmd/Ctrl+Enter sends. On desktop, bare Enter sends.
const shouldSend = isTouchDevice ? event.metaKey || event.ctrlKey : !event.shiftKey;
if (shouldSend) {
void onSend();
return true;
}
return false;
}
return false;
};
Expand Down Expand Up @@ -1798,6 +1820,39 @@ export const ChatComposer = memo(
],
);

// ------------------------------------------------------------------
// Mobile collapse: blur scheduling
// ------------------------------------------------------------------
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);
requestAnimationFrame(() => {
window.scrollTo(0, 0);
});
});
}, []);

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

// Render
// ------------------------------------------------------------------
return (
Expand All @@ -1818,43 +1873,104 @@ export const ChatComposer = memo(
onDrop={onComposerDrop}
>
<div
ref={composerSurfaceRef}
className={cn(
"rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible: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) {
flushSync(() => {
setIsComposerFocused(true);
});
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}
onToggleOption={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}
onToggleOption={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)}

{/* Collapsed mobile preview */}
{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"
disabled={isSendBusy || isConnecting || !composerSendState.hasSendableContent}
aria-label="Send message"
onPointerDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
void 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 @@ -1876,7 +1992,8 @@ export const ChatComposer = memo(
</div>
)}

{!isComposerApprovalState &&
{!isComposerCollapsedMobile &&
!isComposerApprovalState &&
pendingUserInputs.length === 0 &&
composerImages.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
Expand Down Expand Up @@ -1979,7 +2096,7 @@ export const ChatComposer = memo(
</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
4 changes: 4 additions & 0 deletions apps/web/src/components/chat/ComposerPrimaryActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
type="submit"
size="sm"
className={cn("rounded-full", compact ? "px-3" : "px-4")}
onPointerDown={(e) => e.preventDefault()}
disabled={
pendingAction.isResponding ||
(pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance)
Expand Down Expand Up @@ -128,6 +129,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
type="submit"
size="sm"
className={cn("rounded-full", compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8")}
onPointerDown={(e) => e.preventDefault()}
disabled={isSendBusy || isConnecting}
>
{isConnecting || isSendBusy ? "Sending..." : "Refine"}
Expand All @@ -141,6 +143,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
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 @@ -176,6 +179,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
<button
type="submit"
className="flex h-9 w-9 enabled:cursor-pointer items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:pointer-events-none disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8"
onPointerDown={(e) => e.preventDefault()}
disabled={isSendBusy || isConnecting || !hasSendableContent}
aria-label={
isConnecting
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/hooks/useMediaQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,8 @@ export function useMediaQuery(query: BreakpointQuery | MediaQueryInput | (string
export function useIsMobile(): boolean {
return useMediaQuery("max-md");
}

/** Returns `true` when the primary pointing device is coarse (finger / stylus). */
export function useIsTouchDevice(): boolean {
return useMediaQuery({ pointer: "coarse" });
}
13 changes: 13 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@
}
}

@layer utilities {
.h-app-viewport {
height: var(--app-visual-viewport-height, 100dvh);
}

.safe-area-top-x {
box-sizing: border-box;
padding-top: env(safe-area-inset-top, 0px);
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
}
}

/* Suppress all transitions during theme changes */
.no-transitions,
.no-transitions *,
Expand Down
Loading
Loading