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
10 changes: 9 additions & 1 deletion web/src/components/agent-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,7 @@ export function AgentChat({
dep={display}
onAtBottomChange={setFollowing}
hasNew={hasNew}
showJumpButton={false}
className="px-2 py-3"
>
{display ? (
Expand Down Expand Up @@ -767,7 +768,14 @@ export function AgentChat({
stepFontSize={stepFontSize}
setRawTerminal={setRawTerminal}
onSent={onSent}
// Find-in-output lives in the composer's View row now (the header was the wrong home for it).
canScrollToLatest={!following}
hasNewOutput={hasNew}
onScrollToLatest={() => {
setFollowing(true);
setShown({ text, revision });
listRef.current?.scrollToBottom();
}}
// Find-in-output lives in the composer's terminal toolbar now (the header was the wrong home for it).
// Enabled only when there's buffered output to search; opening it freezes the tail (openFind).
onOpenFind={display ? openFind : undefined}
/>
Expand Down
24 changes: 24 additions & 0 deletions web/src/components/composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,30 @@ describe("Composer — quick keys / image attach", () => {
expect(screen.queryByRole("button", { name: d })).not.toBeInTheDocument();
}
});

it("keeps Keys, Quick and Agent as icon-only toolbar buttons", () => {
renderComposer();

for (const label of ["Keys", "Quick", "Agent"]) {
expect(screen.getByRole("button", { name: label })).toBeInTheDocument();
expect(screen.queryByText(label)).not.toBeInTheDocument();
}
expect(screen.getByRole("button", { name: "Agent" }).querySelector("svg")).toHaveClass(
"lucide-bot",
);
});

it("uses the toolbar down-arrow to return to the live terminal tail", async () => {
const user = userEvent.setup();
const onScrollToLatest = vi.fn();
renderComposer({ canScrollToLatest: true, hasNewOutput: true, onScrollToLatest });

const button = screen.getByRole("button", { name: "Scroll to latest" });
expect(button).toBeEnabled();
await user.click(button);

expect(onScrollToLatest).toHaveBeenCalledTimes(1);
});
});

describe("Composer — clipboard image paste", () => {
Expand Down
247 changes: 144 additions & 103 deletions web/src/components/composer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import type { ChangeEvent, ClipboardEvent, ReactNode } from "react";
import { useRevalidator } from "react-router";
import { AArrowDown, AArrowUp, Check, ImagePlus, Keyboard, Loader2, Search, Send, Slash, Terminal, WrapText, X, Zap } from "lucide-react";
import {
AArrowDown,
AArrowUp,
ArrowDown,
Bot,
Check,
ImagePlus,
Keyboard,
Loader2,
Search,
Send,
Terminal,
WrapText,
X,
Zap,
} from "lucide-react";

import type { DisplayPrefs } from "@/hooks/use-display-prefs";
import { usePendingConfirm } from "@/hooks/use-pending-confirm";
Expand Down Expand Up @@ -61,6 +76,12 @@ interface ComposerProps {
/** Open find-in-output (freezes the tail in AgentChat). Undefined when there's no buffered output
* to search — the View-row Find button hides in that case. */
onOpenFind?: () => void;
/** Jump the terminal mirror back to the live tail. */
onScrollToLatest?: () => void;
/** Whether the terminal mirror is currently away from the live tail. */
canScrollToLatest?: boolean;
/** Whether newer output arrived while the mirror was frozen/scrolled up. */
hasNewOutput?: boolean;
}

// The composer cluster at the bottom of the pane view — everything a phone keyboard can't do on its
Expand Down Expand Up @@ -115,7 +136,27 @@ function ComposerDock({
}

export const Composer = forwardRef<ComposerHandle, ComposerProps>(function Composer(
{ paneId, session, agent, isShell, gone, readOnly, dialogPresent, text, terminalDraft, rawTerminalDraft, prefs, setWrap, stepFontSize, setRawTerminal, onSent, onOpenFind },
{
paneId,
session,
agent,
isShell,
gone,
readOnly,
dialogPresent,
text,
terminalDraft,
rawTerminalDraft,
prefs,
setWrap,
stepFontSize,
setRawTerminal,
onSent,
onOpenFind,
onScrollToLatest,
canScrollToLatest = false,
hasNewOutput = false,
},
ref,
) {
const revalidator = useRevalidator();
Expand Down Expand Up @@ -468,140 +509,140 @@ export const Composer = forwardRef<ComposerHandle, ComposerProps>(function Compo
below (always visible, not gated behind the keyboard-open quick keys); structural commands
(New tab/space, Kill) and Stop (Esc, in the Keys dock) live elsewhere. */}
<input ref={fileRef} type="file" accept="image/*" hidden onChange={onPickImage} />
{/* Display prefs (wrap + font size) on their own compact, right-aligned row. Kept off the
Keys/Quick/Agent action row below — three extra buttons there overflowed a narrow phone
and broke the layout. */}
<div className="mb-2 flex items-center gap-1">
<SectionLabel>View</SectionLabel>
<div className="ml-auto flex items-center gap-1">
{/* Find in output — search the already-fetched pane buffer without leaving the pane.
Lives here (not the header) so search sits with the other view controls; only shown
when AgentChat passes a handler (i.e. there's buffered output to search). */}
{onOpenFind && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
onClick={onOpenFind}
aria-label="Find in output"
title="Find in output"
>
<Search className="size-3.5" />
</Button>
)}
{/* Raw-terminal escape hatch: turns off the block renderer (native prompt buttons, chrome
strip, status strip) so a mis-parsed dialog can always be driven by hand with the keys
pad. Highlighted when active so it's obvious the plain mirror is showing. */}
<Button
variant={prefs.rawTerminal ? "secondary" : "ghost"}
size="icon"
className="h-7 w-7 text-muted-foreground"
onClick={() => setRawTerminal(!prefs.rawTerminal)}
aria-label={
prefs.rawTerminal
? "Raw terminal on — tap for the enhanced view"
: "Raw terminal off — tap to show the plain terminal"
}
aria-pressed={prefs.rawTerminal}
title="Toggle raw terminal (disable native prompt buttons)"
>
<Terminal className="size-3.5" />
</Button>
<Button
variant={prefs.wrap ? "secondary" : "ghost"}
size="icon"
className="h-7 w-7 text-muted-foreground"
onClick={() => setWrap(!prefs.wrap)}
aria-label={prefs.wrap ? "Wrap on — tap to disable" : "Wrap off — tap to enable"}
aria-pressed={prefs.wrap}
title="Toggle line wrap"
>
<WrapText className="size-3.5" />
</Button>
{/* One thumb row for terminal view and input controls. It scrolls horizontally on the
narrowest phones, keeping all controls in one stable line without shrinking targets. */}
<div className="mb-2 -mx-1 flex items-center gap-1 overflow-x-auto overscroll-x-contain px-1 pb-0.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{onOpenFind && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
disabled={prefs.fontSize <= 9}
onClick={() => stepFontSize(-1)}
aria-label="Decrease font size"
title="Smaller text"
className="h-8 w-8 shrink-0 text-muted-foreground"
onClick={onOpenFind}
aria-label="Find in output"
title="Find in output"
>
<AArrowDown className="size-3.5" />
<Search className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
disabled={prefs.fontSize >= 16}
onClick={() => stepFontSize(1)}
aria-label="Increase font size"
title="Larger text"
>
<AArrowUp className="size-3.5" />
</Button>
</div>
</div>
{/* Keys / Quick dock — a single in-flow site ABOVE the Controls row (so the toggle you tapped
stays put and the panel grows over the mirror, not the input). Whichever of the mutually
exclusive drawers is active renders here via the shared ComposerDock chrome. Keys mounts
the NavTray (unmounts on close, so tab/queue reset each open); Quick mounts the two
one-tap reply grids. Agent stays a covering BottomSheet below (it's a palette, not a pad). */}
{drawer === "keys" && (
<ComposerDock title="Keys" onClose={closeDrawer}>
<NavTray onSend={pressKeys} disabled={locked} />
</ComposerDock>
)}
{drawer === "quick" && (
<ComposerDock title="Quick" onClose={closeDrawer}>
<QuickActionsContent
onSend={(t) => send(t, false)}
onClose={closeDrawer}
disabled={locked || sending}
/>
</ComposerDock>
)}
{/* Action row: Keys · Quick · Agent (Agent only when the pane's agent has commands). */}
<div className="mb-2 flex items-center gap-2">
<SectionLabel>Controls</SectionLabel>
)}
<Button
variant={prefs.rawTerminal ? "secondary" : "ghost"}
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground"
onClick={() => setRawTerminal(!prefs.rawTerminal)}
aria-label={
prefs.rawTerminal
? "Raw terminal on — tap for the enhanced view"
: "Raw terminal off — tap to show the plain terminal"
}
aria-pressed={prefs.rawTerminal}
title="Toggle raw terminal (disable native prompt buttons)"
>
<Terminal className="size-3.5" />
</Button>
<Button
variant={prefs.wrap ? "secondary" : "ghost"}
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground"
onClick={() => setWrap(!prefs.wrap)}
aria-label={prefs.wrap ? "Wrap on — tap to disable" : "Wrap off — tap to enable"}
aria-pressed={prefs.wrap}
title="Toggle line wrap"
>
<WrapText className="size-3.5" />
</Button>
<Button
variant={hasNewOutput ? "secondary" : "ghost"}
size="icon"
className="relative h-8 w-8 shrink-0 text-muted-foreground"
disabled={!canScrollToLatest}
onClick={onScrollToLatest}
aria-label="Scroll to latest"
title="Scroll to latest"
>
<ArrowDown className="size-3.5" />
{hasNewOutput && (
<span className="absolute right-1 top-1 size-2 rounded-full bg-status-blocked ring-2 ring-background" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground"
disabled={prefs.fontSize <= 9}
onClick={() => stepFontSize(-1)}
aria-label="Decrease font size"
title="Smaller text"
>
<AArrowDown className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground"
disabled={prefs.fontSize >= 16}
onClick={() => stepFontSize(1)}
aria-label="Increase font size"
title="Larger text"
>
<AArrowUp className="size-3.5" />
</Button>
{/* Keys and Quick are TOGGLES for the in-flow dock above (not overlays): tap to open, tap
again to close. aria-expanded ties each to the dock; secondary variant marks it pressed
while open. Both share the single-valued `drawer`, so opening one closes the other. */}
<Button
variant={drawer === "keys" ? "secondary" : "ghost"}
size="sm"
className="h-8 flex-1 gap-1.5 text-muted-foreground"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground"
disabled={locked}
aria-label="Keys"
aria-expanded={drawer === "keys"}
title="Keys"
onClick={() => setDrawer(drawer === "keys" ? null : "keys")}
>
<Keyboard className="size-4" />
Keys
</Button>
<Button
variant={drawer === "quick" ? "secondary" : "ghost"}
size="sm"
className="h-8 flex-1 gap-1.5 text-muted-foreground"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground"
disabled={locked}
aria-label="Quick"
aria-expanded={drawer === "quick"}
title="Quick"
onClick={() => setDrawer(drawer === "quick" ? null : "quick")}
>
<Zap className="size-4" />
Quick
</Button>
{commands.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 flex-1 gap-1.5 text-muted-foreground"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground"
disabled={locked}
aria-label="Agent"
title="Agent"
onClick={() => setDrawer("cmd")}
>
<Slash className="size-4" />
Agent
<Bot className="size-4" />
</Button>
)}
</div>
{/* Keys / Quick dock — a single in-flow site above the toolbar, so opening it reduces the
mirror rather than covering the terminal tail. */}
{drawer === "keys" && (
<ComposerDock title="Keys" onClose={closeDrawer}>
<NavTray onSend={pressKeys} disabled={locked} />
</ComposerDock>
)}
{drawer === "quick" && (
<ComposerDock title="Quick" onClose={closeDrawer}>
<QuickActionsContent
onSend={(t) => send(t, false)}
onClose={closeDrawer}
disabled={locked || sending}
/>
</ComposerDock>
)}
{/* Terminal-draft preview: a read-only view of a stranded "❯"-line draft (a message queued
then recalled on the HOST, which stripChrome hides from the mirror). It appears only after
the draft stabilises (never a blip/self-echo), then its text tracks the live line — host
Expand Down
6 changes: 4 additions & 2 deletions web/src/components/ui/chat/chat-message-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@ interface ChatMessageListProps extends React.HTMLAttributes<HTMLDivElement> {
onAtBottomChange?: (atBottom: boolean) => void;
/** Dot the "jump to latest" button when newer output arrived while you were scrolled up. */
hasNew?: boolean;
/** Whether this component renders its own floating jump button. */
showJumpButton?: boolean;
}

// Scrollable conversation container that auto-follows new messages and shows a "jump to latest"
// affordance once the user scrolls up. Exposes `scrollToBottom` via ref so the parent can re-follow
// after an action, and reports at-bottom changes so the parent can freeze content while you read.
const ChatMessageList = React.forwardRef<ChatMessageListHandle, ChatMessageListProps>(
function ChatMessageList(
{ className, children, dep, onAtBottomChange, hasNew, ...props },
{ className, children, dep, onAtBottomChange, hasNew, showJumpButton = true, ...props },
ref,
) {
const { scrollRef, isAtBottom, scrollToBottom, onScroll } = useAutoScroll<HTMLDivElement>({
Expand Down Expand Up @@ -56,7 +58,7 @@ const ChatMessageList = React.forwardRef<ChatMessageListHandle, ChatMessageListP
{children}
</div>

{!isAtBottom && (
{showJumpButton && !isAtBottom && (
<Button
onClick={() => scrollToBottom()}
size="icon"
Expand Down