diff --git a/web/src/components/agent-chat.tsx b/web/src/components/agent-chat.tsx index 35bc7c0..9cfe923 100644 --- a/web/src/components/agent-chat.tsx +++ b/web/src/components/agent-chat.tsx @@ -656,6 +656,7 @@ export function AgentChat({ dep={display} onAtBottomChange={setFollowing} hasNew={hasNew} + showJumpButton={false} className="px-2 py-3" > {display ? ( @@ -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} /> diff --git a/web/src/components/composer.test.tsx b/web/src/components/composer.test.tsx index a73735e..7b9c405 100644 --- a/web/src/components/composer.test.tsx +++ b/web/src/components/composer.test.tsx @@ -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", () => { diff --git a/web/src/components/composer.tsx b/web/src/components/composer.tsx index da0751e..cf65d61 100644 --- a/web/src/components/composer.tsx +++ b/web/src/components/composer.tsx @@ -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"; @@ -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 @@ -115,7 +136,27 @@ function ComposerDock({ } export const Composer = forwardRef(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(); @@ -468,140 +509,140 @@ export const Composer = forwardRef(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. */} - {/* 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. */} -
- View -
- {/* 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 && ( - - )} - {/* 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. */} - - + {/* 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. */} +
+ {onOpenFind && ( - -
-
- {/* 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" && ( - - - - )} - {drawer === "quick" && ( - - send(t, false)} - onClose={closeDrawer} - disabled={locked || sending} - /> - - )} - {/* Action row: Keys · Quick · Agent (Agent only when the pane's agent has commands). */} -
- Controls + )} + + + + + {/* 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. */} {commands.length > 0 && ( )}
+ {/* 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" && ( + + + + )} + {drawer === "quick" && ( + + send(t, false)} + onClose={closeDrawer} + disabled={locked || sending} + /> + + )} {/* 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 diff --git a/web/src/components/ui/chat/chat-message-list.tsx b/web/src/components/ui/chat/chat-message-list.tsx index 4ebf401..972c42e 100644 --- a/web/src/components/ui/chat/chat-message-list.tsx +++ b/web/src/components/ui/chat/chat-message-list.tsx @@ -19,6 +19,8 @@ interface ChatMessageListProps extends React.HTMLAttributes { 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" @@ -26,7 +28,7 @@ interface ChatMessageListProps extends React.HTMLAttributes { // after an action, and reports at-bottom changes so the parent can freeze content while you read. const ChatMessageList = React.forwardRef( function ChatMessageList( - { className, children, dep, onAtBottomChange, hasNew, ...props }, + { className, children, dep, onAtBottomChange, hasNew, showJumpButton = true, ...props }, ref, ) { const { scrollRef, isAtBottom, scrollToBottom, onScroll } = useAutoScroll({ @@ -56,7 +58,7 @@ const ChatMessageList = React.forwardRef - {!isAtBottom && ( + {showJumpButton && !isAtBottom && (