feat(web): input history recall, sticky work-group header, chat outline (#164)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -105,6 +105,13 @@ import {
|
||||
skillSlashItems,
|
||||
} from "./skill-use";
|
||||
import { GOAL_ICON, UNLIMITED_BUDGET, parseBudgetInput } from "./goal-use";
|
||||
import {
|
||||
caretOnFirstLine,
|
||||
caretOnLastLine,
|
||||
historyStepBack,
|
||||
historyStepForward,
|
||||
} from "./input-history";
|
||||
import type { HistoryStep } from "./input-history";
|
||||
import { midRunAction } from "./composer-send";
|
||||
import { PAPERCLIP_ICON } from "./attached-files-banner";
|
||||
|
||||
@@ -1173,6 +1180,7 @@ export function ChatInput({
|
||||
onHandoff,
|
||||
initialText,
|
||||
onTextChange,
|
||||
history = [],
|
||||
initialHandoffTargetId,
|
||||
onHandoffTargetChange,
|
||||
initialPendingModelRef,
|
||||
@@ -1319,6 +1327,12 @@ export function ChatInput({
|
||||
* resurrect it.
|
||||
*/
|
||||
onTextChange?: (text: string) => void;
|
||||
/**
|
||||
* This session's previous composer inputs (oldest → newest) for shell-style ↑/↓ recall
|
||||
* (see input-history.ts for what qualifies). Omitted in draft state — a draft has no
|
||||
* history to recall yet, and the arrows then keep their native meaning.
|
||||
*/
|
||||
history?: string[];
|
||||
/** Draft restore: the agentId of the staged handoff target (resolved once agents are ready; discarded if stale). */
|
||||
initialHandoffTargetId?: string;
|
||||
/** Callback when the staged handoff target changes (picked/removed; the clear after a successful send does not call back, same as onTextChange). */
|
||||
@@ -1391,6 +1405,8 @@ export function ChatInput({
|
||||
// Cursor position (tracked via onChange/onSelect): the slash menu matches the token at the caret.
|
||||
const [caret, setCaret] = useState(0);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
// Shell-style ↑/↓ history recall state (null = not navigating); ended by the text-mismatch effect below.
|
||||
const historyNavRef = useRef<HistoryStep["nav"]>(null);
|
||||
// Short placeholder on narrow screens: a long hint would wrap and get clipped in a single-line textarea.
|
||||
const [narrow] = useState(() => window.matchMedia("(max-width: 767px)").matches);
|
||||
|
||||
@@ -1822,6 +1838,15 @@ export function ChatInput({
|
||||
*/
|
||||
useLayoutEffect(autoGrow, [text]);
|
||||
|
||||
// History navigation ends the moment the composer text no longer matches the recalled
|
||||
// entry — one rule covering user edits, slash-command rewrites and the post-send clear
|
||||
// alike (applyHistory updates the text and `recalled` in the same commit, so stepping
|
||||
// itself never trips this).
|
||||
useEffect(() => {
|
||||
const nav = historyNavRef.current;
|
||||
if (nav && text !== nav.recalled) historyNavRef.current = null;
|
||||
}, [text]);
|
||||
|
||||
// Cursor placement on mount: move it to the end of a restored draft (by default the browser
|
||||
// places the cursor at the start when focusing a textarea that already has content), so typing
|
||||
// continues the text naturally, and sync the caret state to match (the slash menu matches the
|
||||
@@ -2036,6 +2061,24 @@ export function ChatInput({
|
||||
await sendNormal();
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies a history step: the recalled text goes through the normal draft path
|
||||
* (onTextChange keeps the draft cache in step) with the caret parked at the end — set
|
||||
* after React commits the new value (setSelectionRange now would act on the old text).
|
||||
*/
|
||||
const applyHistory = (step: HistoryStep) => {
|
||||
historyNavRef.current = step.nav;
|
||||
setText(step.text);
|
||||
onTextChange?.(step.text);
|
||||
setCaret(step.text.length);
|
||||
requestAnimationFrame(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.setSelectionRange(el.value.length, el.value.length);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (slashOpen) {
|
||||
if (e.key === "ArrowDown") {
|
||||
@@ -2061,6 +2104,28 @@ export function ChatInput({
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Shell-style history recall on the arrows (the slash-menu navigation above wins while
|
||||
// that menu is open; IME composition keeps the arrows for candidate-list navigation).
|
||||
// ↑ steps back only from the first line — from an empty draft, or within an unedited
|
||||
// recalled entry once the caret has walked up to line 1 — and ↓ mirrors that on the
|
||||
// last line, so caret movement inside a multi-line text is never hijacked.
|
||||
if ((e.key === "ArrowUp" || e.key === "ArrowDown") && !e.nativeEvent.isComposing) {
|
||||
const caretStart = e.currentTarget.selectionStart ?? 0;
|
||||
const caretEnd = e.currentTarget.selectionEnd ?? caretStart;
|
||||
const step =
|
||||
e.key === "ArrowUp"
|
||||
? caretOnFirstLine(text, caretStart)
|
||||
? historyStepBack(history, historyNavRef.current, text)
|
||||
: null
|
||||
: caretOnLastLine(text, caretEnd)
|
||||
? historyStepForward(history, historyNavRef.current, text)
|
||||
: null;
|
||||
if (step) {
|
||||
e.preventDefault();
|
||||
applyHistory(step);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Backspace at the start of the text: removes the staged switch chip (consistent with common chip-input interaction).
|
||||
if (
|
||||
e.key === "Backspace" &&
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* chosen before sending, and everything except approval mode is locked once the Session is
|
||||
* created. The Session list and the new-chat entry point live in the global sidebar.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import type {
|
||||
@@ -57,7 +57,10 @@ import { MessageStream } from "./message-stream";
|
||||
import type { StreamRenderContext } from "./message-stream";
|
||||
import { latestTaskHasSubagent, taskStartCount } from "./agent-topology";
|
||||
import { ChatInput } from "./chat-input";
|
||||
import { ConversationOutline, OutlineMenuButton, useOutlineRailFit } from "./conversation-outline";
|
||||
import { DraftView } from "./draft-view";
|
||||
import { buildInputHistory } from "./input-history";
|
||||
import { buildOutline } from "./outline-model";
|
||||
import { GoalStatusBanner } from "./goal-banner";
|
||||
import { handoffMessage, modelSwitchMessage } from "./agent-handoff";
|
||||
import { sameModelRef } from "../models/model-grouping";
|
||||
@@ -306,6 +309,27 @@ export function ChatPage() {
|
||||
discard: discardSessionDraft,
|
||||
} = useSessionDraft(selected?.sessionId ?? null);
|
||||
|
||||
// Derivations over the stream items (the model mutates in place, so `version` — its own
|
||||
// repaint signal — keys the memos; the session id covers a switch racing a same-valued
|
||||
// version): the composer's ↑/↓ recall list and the left outline's entries.
|
||||
const inputHistory = useMemo(
|
||||
() => buildInputHistory(stream.model.items),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[stream.version, routeSessionId],
|
||||
);
|
||||
const outline = useMemo(
|
||||
() => buildOutline(stream.model.items),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[stream.version, routeSessionId],
|
||||
);
|
||||
// The message stream's scroll container, exposed by MessageStream for the outline's
|
||||
// jump/scrollspy (anchors are queried inside it, never document-wide).
|
||||
const streamScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
// Which outline shape fits: the gutter tick rail, or (exactly when it can't show —
|
||||
// phones without hover, and any window whose gutter a docked panel ate) the toolbar's
|
||||
// dropdown index button.
|
||||
const railFit = useOutlineRailFit(streamScrollRef, stream.version);
|
||||
|
||||
// Current Agent follows the Session in the route (keeps the sidebar and stats aligned on deep
|
||||
// links / refresh). Only aligns when **the selected Session changes** — never put agentId in
|
||||
// the dependency array: otherwise, when switching from a "running session" to a new chat with
|
||||
@@ -925,6 +949,7 @@ export function ChatPage() {
|
||||
onHandoff={onHandoff}
|
||||
initialText={sessionDraft.text ?? ""}
|
||||
onTextChange={onDraftTextChange}
|
||||
history={inputHistory}
|
||||
{...(sessionDraft.handoffAgentId
|
||||
? { initialHandoffTargetId: sessionDraft.handoffAgentId }
|
||||
: {})}
|
||||
@@ -984,6 +1009,7 @@ export function ChatPage() {
|
||||
aria-expanded={subagentsPanel.open}
|
||||
onClick={() => subagentsPanel.setOpen(!subagentsPanel.open)}
|
||||
title={S.chat.openAgents}
|
||||
aria-label={S.chat.openAgents}
|
||||
className={`flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium transition-colors duration-150 ${
|
||||
subagentsPanel.open
|
||||
? "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
|
||||
@@ -1005,7 +1031,9 @@ export function ChatPage() {
|
||||
<circle cx="19" cy="18.5" r="2.5" />
|
||||
<path d="M7.4 11 16.7 6.6M7.4 13l9.3 4.4" />
|
||||
</svg>
|
||||
{S.chat.openAgents}
|
||||
{/* Below sm the button is icon-only (title/aria keep the name), same rule as the
|
||||
workspace button next to it: the label ate the title's room on phones. */}
|
||||
<span className="hidden sm:inline">{S.chat.openAgents}</span>
|
||||
{/* A pending approval inside a subagent: amber dot (the chip in the stream carries the accessible announcement). */}
|
||||
{anySubagentPending && (
|
||||
<span aria-hidden className="h-1.5 w-1.5 rounded-full bg-amber-500" />
|
||||
@@ -1041,6 +1069,17 @@ export function ChatPage() {
|
||||
<span className="hidden sm:inline">{S.chat.openWorkspace}</span>
|
||||
</button>
|
||||
|
||||
{/* Conversation index fallback: exactly when the gutter tick rail can't show
|
||||
(phones without a hover pointer; a desktop window whose gutter a docked panel
|
||||
ate) the index moves up here as a dropdown — navigation stays reachable. */}
|
||||
{!railFit.shown && (
|
||||
<OutlineMenuButton
|
||||
entries={outline}
|
||||
scrollRef={streamScrollRef}
|
||||
running={stream.taskState !== "idle"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Details popup: Model / Workspace / created time / stats */}
|
||||
<Dropdown
|
||||
open={infoOpen}
|
||||
@@ -1111,7 +1150,7 @@ export function ChatPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body: chat column + the docked Files panel on the right (message file cards jump to and locate a file in the tree via onOpenFile). */}
|
||||
{/* Body: chat column + the docked panels on the right (message file cards jump to and locate a file in the tree via onOpenFile). */}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{draft ? (
|
||||
@@ -1159,6 +1198,19 @@ export function ChatPage() {
|
||||
items={stream.model.items}
|
||||
version={stream.version}
|
||||
ctx={ctx}
|
||||
scrollElRef={streamScrollRef}
|
||||
// Tick-rail minimap over the stream's left gutter (zero layout
|
||||
// width; hides itself when the gutter is too narrow or the
|
||||
// pointer can't hover).
|
||||
outline={
|
||||
<ConversationOutline
|
||||
entries={outline}
|
||||
version={stream.version}
|
||||
scrollRef={streamScrollRef}
|
||||
running={stream.taskState !== "idle"}
|
||||
fit={railFit}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* Conversation minimap, in two shapes sharing one data model (outline-model.ts):
|
||||
*
|
||||
* - `ConversationOutline` — a tick rail overlaying the left gutter of the message stream:
|
||||
* one tick per exchange, the reading position emphasized; hovering (or focusing) a tick
|
||||
* pops a floating preview card (question bold over a truncated reply preview), clicking
|
||||
* jumps. It costs the conversation no width, and the preview card mounts only for the
|
||||
* hovered tick — at rest the rail duplicates no message text into the DOM (which would
|
||||
* pollute text lookup for assistive tech and tests alike). The overlay is
|
||||
* hit-transparent (pointer events only on the ticks), so wheel scrolling anywhere in
|
||||
* the gutter keeps scrolling the stream. Rendered into MessageStream's relative wrapper
|
||||
* via its `outline` slot, so the rail spans exactly the stream area — never the composer.
|
||||
*
|
||||
* - `OutlineMenuButton` — the fallback for when the rail cannot show: a toolbar icon
|
||||
* button (top right) opening a dropdown index of the same entries, tap to jump. Phones
|
||||
* are the primary case (no hover pointer, no gutter), but it also covers a desktop
|
||||
* window whose gutter a docked panel has eaten — navigation stays reachable either way.
|
||||
*
|
||||
* Which shape shows is the owner's call via `useOutlineRailFit`: the rail needs a
|
||||
* hover-capable pointer and a live-measured gutter (ResizeObserver — window resizes and
|
||||
* panel drags both count), and the menu button renders exactly when the rail cannot.
|
||||
*
|
||||
* Jump targets are the [data-outline-anchor] wrappers MessageItems stamps at the top
|
||||
* level only, queried scoped to the stream's scroll container (item ids repeat across
|
||||
* nested subagent models, so document-wide lookups would be ambiguous). The active-entry
|
||||
* computation considers only anchors that ARE entries: banner-only messages, merged image
|
||||
* fragments and later goal rounds carry anchors too, and crossing one of those must
|
||||
* highlight the entry that covers it rather than nothing.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { FocusEvent, MouseEvent, RefObject } from "react";
|
||||
import { S } from "../../lib/strings";
|
||||
import { Dropdown } from "../../components/ui/dropdown";
|
||||
import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||||
import type { OutlineEntry } from "./outline-model";
|
||||
import { previewText } from "./outline-model";
|
||||
|
||||
/** Panel-with-list glyph (24×24 line path) for the toolbar menu button. */
|
||||
const OUTLINE_ICON =
|
||||
"M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zm4 0v16M12 9h5m-5 4h5";
|
||||
|
||||
/** Distance of the scrollspy "reading line" below the scrollport top: the entry whose anchor last crossed it counts as active. */
|
||||
const READING_LINE_PX = 96;
|
||||
|
||||
/** Flash animation length on the landed-on message; must outlast the CSS animation (900ms). */
|
||||
const FLASH_MS = 1000;
|
||||
|
||||
/**
|
||||
* Minimum free gutter (stream-container width minus the max-w-3xl column, halved) for the
|
||||
* rail to show: below this the ticks would sit on top of assistant text instead of blank
|
||||
* margin.
|
||||
*/
|
||||
const GUTTER_MIN_PX = 56;
|
||||
|
||||
/** The stream column cap the gutter derives from (Tailwind max-w-3xl). */
|
||||
const COLUMN_MAX_PX = 768;
|
||||
|
||||
/** The rail's hover-preview interaction needs a pointer that can hover. */
|
||||
const HOVER_QUERY = "(hover: hover) and (pointer: fine)";
|
||||
|
||||
/** Tick pitch bounds (px): compress toward MIN as turns outgrow the rail, never past hoverability. */
|
||||
const TICK_PITCH_MAX = 12;
|
||||
const TICK_PITCH_MIN = 5;
|
||||
|
||||
export interface OutlineRailFit {
|
||||
/** Whether the rail can show: hover-capable pointer AND a wide-enough measured gutter. */
|
||||
shown: boolean;
|
||||
/** Stream container height (the tick pitch divides it; the preview card clamps against it). */
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live rail-fit measurement, owned by the page so the toolbar fallback can render exactly
|
||||
* when the rail cannot. Keyed on `version` besides the ref: the scroll container remounts
|
||||
* on a session switch (keyed subtree) without the page remounting, and the observer must
|
||||
* re-attach to the new element.
|
||||
*/
|
||||
export function useOutlineRailFit(
|
||||
scrollRef: RefObject<HTMLDivElement | null>,
|
||||
version: number,
|
||||
): OutlineRailFit {
|
||||
const [pointerFine, setPointerFine] = useState(() => window.matchMedia(HOVER_QUERY).matches);
|
||||
const [fit, setFit] = useState<{ gutterOk: boolean; height: number }>({
|
||||
gutterOk: false,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(HOVER_QUERY);
|
||||
const onChange = (e: MediaQueryListEvent) => setPointerFine(e.matches);
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const gutter = (el.clientWidth - COLUMN_MAX_PX) / 2;
|
||||
setFit({ gutterOk: gutter >= GUTTER_MIN_PX, height: el.clientHeight });
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
// The element is read from the ref per run; `version` is the remount signal.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scrollRef, version]);
|
||||
|
||||
return { shown: pointerFine && fit.gutterOk, height: fit.height };
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry whose exchange the reading position is inside: the last ENTRY anchor above the
|
||||
* reading line — anchors that aren't entries (banner-only messages, image fragments merged
|
||||
* into their entry, goal rounds past 1) resolve to the entry covering them by simply being
|
||||
* skipped. At (or near) the bottom the newest entry wins outright: a short last turn never
|
||||
* crosses the reading line on its own.
|
||||
*/
|
||||
function computeActiveAnchor(container: HTMLElement, entryIds: ReadonlySet<number>): number | null {
|
||||
const anchors = container.querySelectorAll<HTMLElement>("[data-outline-anchor]");
|
||||
if (anchors.length === 0) return null;
|
||||
let last: number | null = null;
|
||||
let active: number | null = null;
|
||||
const line = container.scrollTop + READING_LINE_PX;
|
||||
for (const anchor of anchors) {
|
||||
const id = Number(anchor.dataset["outlineAnchor"]);
|
||||
if (!entryIds.has(id)) continue;
|
||||
last = id;
|
||||
if (anchor.offsetTop <= line) active = id;
|
||||
}
|
||||
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 2;
|
||||
return atBottom ? last : active;
|
||||
}
|
||||
|
||||
/** Flash bookkeeping for jumps: one wash at a time; a timer firing on a detached node is a no-op, so no unmount cleanup is needed. */
|
||||
let flashedAnchor: HTMLElement | null = null;
|
||||
let flashTimer: number | undefined;
|
||||
|
||||
/** Scrolls the stream to an entry's anchor and flashes the landed-on message. */
|
||||
function jumpToAnchor(container: HTMLElement | null, id: number): void {
|
||||
const anchor = container?.querySelector<HTMLElement>(`[data-outline-anchor="${id}"]`);
|
||||
if (!container || !anchor) return;
|
||||
// Instant jump (the glide is for returning to the live bottom, not for navigation);
|
||||
// the resulting scroll event lets stream-follow exit/resume by its own rules.
|
||||
container.scrollTo({ top: Math.max(0, anchor.offsetTop - 8) });
|
||||
// Landing feedback: a brief background wash on the message. Applied via classList — the
|
||||
// anchor wrapper renders without className, so React re-renders during streaming won't
|
||||
// strip the class mid-animation.
|
||||
window.clearTimeout(flashTimer);
|
||||
flashedAnchor?.classList.remove("outline-flash");
|
||||
flashedAnchor = anchor;
|
||||
anchor.classList.remove("outline-flash");
|
||||
void anchor.offsetWidth; // restart the animation when re-jumping to the same entry
|
||||
anchor.classList.add("outline-flash");
|
||||
flashTimer = window.setTimeout(() => anchor.classList.remove("outline-flash"), FLASH_MS);
|
||||
}
|
||||
|
||||
/** The turn's reply preview for a card/menu row: text, an "answering" pulse for the newest running turn, or "". */
|
||||
function answerPreview(
|
||||
entry: OutlineEntry,
|
||||
entries: readonly OutlineEntry[],
|
||||
running: boolean,
|
||||
max: number,
|
||||
): string {
|
||||
if (entry.answer) return previewText(entry.answer, max);
|
||||
return running && entry === entries[entries.length - 1] ? S.chat.outlineAnswering : "";
|
||||
}
|
||||
|
||||
export function ConversationOutline({
|
||||
entries,
|
||||
version,
|
||||
scrollRef,
|
||||
running,
|
||||
fit,
|
||||
}: {
|
||||
entries: OutlineEntry[];
|
||||
/** Stream repaint signal: re-runs the scrollspy as content grows or the stream remounts. */
|
||||
version: number;
|
||||
/** MessageStream's scroll container (null while the stream isn't mounted, e.g. the empty greeting). */
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
/** Whether a Task is running: the newest entry's card then previews "answering" while its reply text hasn't started. */
|
||||
running: boolean;
|
||||
/** The page-owned rail-fit measurement (shared with the toolbar fallback's visibility). */
|
||||
fit: OutlineRailFit;
|
||||
}) {
|
||||
const [activeId, setActiveId] = useState<number | null>(null);
|
||||
/** Hovered/focused tick: which entry to preview, and the tick's center Y within the overlay (the card anchors there). */
|
||||
const [hover, setHover] = useState<{ id: number; top: number } | null>(null);
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
|
||||
// Scrollspy: recomputed on scroll (rAF-throttled) and on every version bump — streaming
|
||||
// growth moves anchors without firing a scroll event. Listener re-attachment per bump is
|
||||
// cheap, and keying on version also re-binds after the stream remounts on a session switch.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!fit.shown || !el || entries.length === 0) return;
|
||||
const ids = new Set(entries.map((entry) => entry.anchorId));
|
||||
let raf: number | null = null;
|
||||
const compute = () => {
|
||||
raf = null;
|
||||
const container = scrollRef.current;
|
||||
if (container) setActiveId(computeActiveAnchor(container, ids));
|
||||
};
|
||||
const onScroll = () => {
|
||||
raf ??= requestAnimationFrame(compute);
|
||||
};
|
||||
compute();
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener("scroll", onScroll);
|
||||
if (raf !== null) cancelAnimationFrame(raf);
|
||||
};
|
||||
// `entries` is rebuilt per version; length + version cover it.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fit.shown, scrollRef, entries.length, version]);
|
||||
|
||||
if (!fit.shown || entries.length === 0) return null;
|
||||
|
||||
/** Tick center Y relative to the rail overlay (the preview card anchors to it, clamped in render). */
|
||||
const tickTop = (e: MouseEvent<HTMLElement> | FocusEvent<HTMLElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
return rect.top + rect.height / 2 - (navRef.current?.getBoundingClientRect().top ?? 0);
|
||||
};
|
||||
|
||||
// Compress the pitch as turns outgrow the rail (~32px breathing room); past ~140 turns
|
||||
// at minimum pitch the stack simply clips — a conversation that long stopped being
|
||||
// scannable by any other means well before the map does.
|
||||
const pitch = Math.max(
|
||||
TICK_PITCH_MIN,
|
||||
Math.min(TICK_PITCH_MAX, Math.floor((fit.height - 32) / entries.length)),
|
||||
);
|
||||
const hovered = hover === null ? null : (entries.find((en) => en.anchorId === hover.id) ?? null);
|
||||
const cardAnswer = hovered === null ? "" : answerPreview(hovered, entries, running, 160);
|
||||
|
||||
return (
|
||||
// Hit-transparent overlay (pointer events only on the tick buttons): the wheel keeps
|
||||
// scrolling the stream everywhere in the gutter. z level with the back-to-bottom
|
||||
// overlay (z-10), below dropdowns (z-40).
|
||||
<nav
|
||||
ref={navRef}
|
||||
aria-label={S.chat.outlineTitle}
|
||||
className="pointer-events-none absolute inset-y-0 left-0 z-10 flex flex-col items-start justify-center"
|
||||
>
|
||||
<div>
|
||||
{entries.map((entry, i) => {
|
||||
const active = entry.anchorId === activeId;
|
||||
return (
|
||||
<button
|
||||
key={entry.anchorId}
|
||||
type="button"
|
||||
data-outline-tick={entry.anchorId}
|
||||
aria-current={active || undefined}
|
||||
aria-label={S.chat.outlineTickLabel(i + 1, entry.question || S.chat.outlineNoText)}
|
||||
style={{ height: pitch }}
|
||||
onMouseEnter={(e) => setHover({ id: entry.anchorId, top: tickTop(e) })}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
onFocus={(e) => setHover({ id: entry.anchorId, top: tickTop(e) })}
|
||||
onBlur={() => setHover(null)}
|
||||
onClick={() => {
|
||||
jumpToAnchor(scrollRef.current, entry.anchorId);
|
||||
setActiveId(entry.anchorId);
|
||||
}}
|
||||
className="group/tick pointer-events-auto flex w-10 items-center pl-2.5"
|
||||
>
|
||||
{/* The visible tick: a short bar, longer and darker at the reading position
|
||||
(the button is the real hit target — pitch-tall and wider than the bar). */}
|
||||
<span
|
||||
className={`h-[2px] rounded-full transition-all duration-150 ${
|
||||
active
|
||||
? "w-5 bg-gray-800 dark:bg-gray-200"
|
||||
: "w-3.5 bg-gray-300 group-hover/tick:bg-gray-500 dark:bg-gray-700 dark:group-hover/tick:bg-gray-400"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Preview card for the hovered/focused tick only — never mounted at rest. Purely a
|
||||
tooltip: hit-transparent (moving the mouse toward it can't trap hover) and hidden
|
||||
from assistive tech (the tick's aria-label already names the turn). */}
|
||||
{hovered && (
|
||||
<div
|
||||
data-outline-card
|
||||
aria-hidden
|
||||
className="anim-pop pointer-events-none absolute left-10 w-80 -translate-y-1/2 rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-lg dark:border-gray-700 dark:bg-gray-900"
|
||||
style={{ top: Math.min(Math.max(hover!.top, 56), Math.max(56, fit.height - 56)) }}
|
||||
>
|
||||
<p
|
||||
className={`truncate text-sm ${
|
||||
hovered.question === ""
|
||||
? "italic text-gray-500 dark:text-gray-400"
|
||||
: "font-semibold text-gray-900 dark:text-gray-100"
|
||||
}`}
|
||||
>
|
||||
{hovered.question || S.chat.outlineNoText}
|
||||
</p>
|
||||
{cardAnswer !== "" && (
|
||||
<p
|
||||
className={`mt-1 line-clamp-3 text-sm leading-snug text-gray-500 dark:text-gray-400 ${
|
||||
hovered.answer === "" ? "animate-pulse" : ""
|
||||
}`}
|
||||
>
|
||||
{cardAnswer}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbar fallback (rendered by the page exactly when the rail cannot show): an icon
|
||||
* button opening a dropdown index of the exchanges — question bold, truncated reply
|
||||
* preview under it, the exchange at the reading position highlighted (computed once per
|
||||
* open; the list isn't live while a dropdown covers the stream) — tap to jump and close.
|
||||
*/
|
||||
export function OutlineMenuButton({
|
||||
entries,
|
||||
scrollRef,
|
||||
running,
|
||||
}: {
|
||||
entries: OutlineEntry[];
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
running: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeId, setActiveId] = useState<number | null>(null);
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
const setOpenComputing = (next: boolean) => {
|
||||
if (next && scrollRef.current) {
|
||||
setActiveId(
|
||||
computeActiveAnchor(scrollRef.current, new Set(entries.map((entry) => entry.anchorId))),
|
||||
);
|
||||
}
|
||||
setOpen(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
open={open}
|
||||
setOpen={setOpenComputing}
|
||||
menuClass="right-0 top-full mt-1 w-72 max-w-[calc(100vw-1.5rem)] origin-top-right"
|
||||
button={
|
||||
<button
|
||||
type="button"
|
||||
title={S.chat.outlineTitle}
|
||||
aria-label={S.chat.outlineTitle}
|
||||
onClick={() => setOpenComputing(!open)}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
>
|
||||
<GlyphIcon d={OUTLINE_ICON} size={15} />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="max-h-[60vh] overflow-y-auto py-1">
|
||||
{entries.map((entry) => {
|
||||
const answer = answerPreview(entry, entries, running, 80);
|
||||
const active = entry.anchorId === activeId;
|
||||
return (
|
||||
<button
|
||||
key={entry.anchorId}
|
||||
type="button"
|
||||
data-outline-menu-entry={entry.anchorId}
|
||||
aria-current={active || undefined}
|
||||
onClick={() => {
|
||||
jumpToAnchor(scrollRef.current, entry.anchorId);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`block w-full px-3 py-1.5 text-left transition-colors duration-150 ${
|
||||
active
|
||||
? "bg-gray-100 dark:bg-gray-800"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-800/60"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`block truncate text-sm ${
|
||||
entry.question === ""
|
||||
? "italic text-gray-500 dark:text-gray-400"
|
||||
: "font-medium text-gray-800 dark:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
{entry.question || S.chat.outlineNoText}
|
||||
</span>
|
||||
{answer !== "" && (
|
||||
<span
|
||||
className={`mt-0.5 block truncate text-xs text-gray-500 dark:text-gray-400 ${
|
||||
entry.answer === "" ? "animate-pulse" : ""
|
||||
}`}
|
||||
>
|
||||
{answer}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Composer input history (shell-style ↑/↓ recall): pure logic, unit-testable.
|
||||
*
|
||||
* History list: the session's previous composer inputs, oldest → newest, derived from the
|
||||
* stream items. Only text the user actually typed into the composer qualifies — regular
|
||||
* prompts (protocol blocks and attachment lines stripped) and mid-run steering messages.
|
||||
* Machine-injected texts are excluded: handoff / model-switch source blocks carry no user
|
||||
* prose, scheduled-trigger prompts were authored in the schedule config rather than typed
|
||||
* here, and goal rounds past 1 are the loop re-sending an objective already in the list.
|
||||
* Consecutive duplicates collapse (recalling the same text twice in a row is one entry),
|
||||
* matching shell behavior.
|
||||
*
|
||||
* Navigation: a small state machine the composer drives from ArrowUp/ArrowDown.
|
||||
* - Stepping back only starts from an effectively empty draft (a non-empty draft means the
|
||||
* user is writing, and hijacking ↑ there would break in-text caret movement); whatever
|
||||
* was in the box is stashed and restored when stepping forward past the newest entry.
|
||||
* - While navigating, any edit ends the session implicitly: the composer compares the
|
||||
* current text against `recalled` and both steppers bail on a mismatch, handing ↑/↓ back
|
||||
* to the caret. The oldest entry pins (repeated ↑ stays put, like a shell at the top of
|
||||
* its history).
|
||||
* - The caret-line guards mirror multi-line shell behavior: within a recalled multi-line
|
||||
* entry, ↑/↓ first walk the caret through the lines; only ↑ on the first line / ↓ on the
|
||||
* last line step the history.
|
||||
*/
|
||||
import type { ChatItem } from "../../lib/omni/stream-model";
|
||||
import { splitAttachments } from "../../lib/attachments";
|
||||
import { parseUserMessageBody } from "./user-message-body";
|
||||
|
||||
/** Active navigation state; null in the composer means "not navigating". */
|
||||
export interface HistoryNav {
|
||||
/** Index into the history list of the entry currently recalled into the composer. */
|
||||
index: number;
|
||||
/** The draft text as it was when navigation began; restored when stepping past the newest entry. */
|
||||
stash: string;
|
||||
/** The text the last step wrote into the composer; a mismatch with the live text means the user edited, which ends navigation. */
|
||||
recalled: string;
|
||||
}
|
||||
|
||||
/** A step's outcome: the new navigation state (null = navigation ended) and the text to put in the composer. */
|
||||
export interface HistoryStep {
|
||||
nav: HistoryNav | null;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Derives the recallable history (oldest → newest) from the stream items. */
|
||||
export function buildInputHistory(items: readonly ChatItem[]): string[] {
|
||||
const out: string[] = [];
|
||||
const push = (text: string) => {
|
||||
if (text !== "" && out[out.length - 1] !== text) out.push(text);
|
||||
};
|
||||
for (const item of items) {
|
||||
if (item.kind === "user_text") {
|
||||
const parsed = parseUserMessageBody(item.text);
|
||||
if (!parsed || parsed.scheduled) continue;
|
||||
if (parsed.goalRound !== undefined && parsed.goalRound > 1) continue;
|
||||
push(parsed.body);
|
||||
} else if (item.kind === "user_steering") {
|
||||
push(splitAttachments(item.text).text.trim());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** ↑: step to an older entry. Returns null when the key should keep its native meaning. */
|
||||
export function historyStepBack(
|
||||
history: readonly string[],
|
||||
nav: HistoryNav | null,
|
||||
current: string,
|
||||
): HistoryStep | null {
|
||||
if (history.length === 0) return null;
|
||||
if (nav === null) {
|
||||
// Only an (effectively) empty draft starts navigation; the stash keeps it verbatim.
|
||||
if (current.trim() !== "") return null;
|
||||
const index = history.length - 1;
|
||||
const text = history[index]!;
|
||||
return { nav: { index, stash: current, recalled: text }, text };
|
||||
}
|
||||
if (current !== nav.recalled) return null; // edited: navigation is over
|
||||
if (nav.index === 0) return { nav, text: nav.recalled }; // pinned at the oldest entry
|
||||
const index = nav.index - 1;
|
||||
const text = history[index]!;
|
||||
return { nav: { ...nav, index, recalled: text }, text };
|
||||
}
|
||||
|
||||
/** ↓: step to a newer entry; past the newest restores the stashed draft and ends navigation. */
|
||||
export function historyStepForward(
|
||||
history: readonly string[],
|
||||
nav: HistoryNav | null,
|
||||
current: string,
|
||||
): HistoryStep | null {
|
||||
if (nav === null || current !== nav.recalled) return null;
|
||||
if (nav.index >= history.length - 1) return { nav: null, text: nav.stash };
|
||||
const index = nav.index + 1;
|
||||
const text = history[index]!;
|
||||
return { nav: { ...nav, index, recalled: text }, text };
|
||||
}
|
||||
|
||||
/** True when no newline separates the caret from the start of the text (the caret sits on line 1). */
|
||||
export function caretOnFirstLine(text: string, caret: number): boolean {
|
||||
return !text.slice(0, caret).includes("\n");
|
||||
}
|
||||
|
||||
/** True when no newline separates the caret from the end of the text (the caret sits on the last line). */
|
||||
export function caretOnLastLine(text: string, caret: number): boolean {
|
||||
return !text.slice(caret).includes("\n");
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
* cards at any nesting depth.
|
||||
*/
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import { S } from "../../lib/strings";
|
||||
import type { ChatItem } from "../../lib/omni/stream-model";
|
||||
import type { TaskStats } from "../../lib/omni/task-stats";
|
||||
@@ -129,7 +129,21 @@ export function MessageItems({ items, ctx }: { items: ChatItem[]; ctx: StreamRen
|
||||
seg.type === "single" && (seg.item.kind === "user_text" || seg.item.kind === "user_image");
|
||||
if (isUserMsg) {
|
||||
flushTurn();
|
||||
nodes.push(renderSeg(seg, i));
|
||||
// Outline jump anchor, top level only (ctx.origin is empty just for the main
|
||||
// conversation): item ids restart per model, so stamping nested renders — subagent
|
||||
// conversations in the panel — would duplicate anchor values; the outline queries
|
||||
// them scoped to the main stream's scroll container. The wrapper stays classless:
|
||||
// margins collapse straight through it, and the outline's transient flash class
|
||||
// lives outside React's managed props (className would wipe it on re-render).
|
||||
nodes.push(
|
||||
ctx.origin.length === 0 ? (
|
||||
<div key={`anchor-${seg.item.id}`} data-outline-anchor={seg.item.id}>
|
||||
{renderSeg(seg, i)}
|
||||
</div>
|
||||
) : (
|
||||
renderSeg(seg, i)
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
turn.push({ seg, i });
|
||||
@@ -145,11 +159,21 @@ export function MessageStream({
|
||||
items,
|
||||
version,
|
||||
ctx,
|
||||
scrollElRef,
|
||||
outline,
|
||||
}: {
|
||||
items: ChatItem[];
|
||||
/** View-model version number (a repaint signal for in-place updates that also drives auto-scroll). */
|
||||
version: number;
|
||||
ctx: StreamRenderContext;
|
||||
/** Mirrors the scroll container element out to the owner (the conversation outline's jump/scrollspy target). */
|
||||
scrollElRef?: RefObject<HTMLDivElement | null>;
|
||||
/**
|
||||
* Overlay slot rendered inside the stream's positioning wrapper (the conversation
|
||||
* outline's tick rail): the rail must span exactly the stream area — not the composer —
|
||||
* and anchor its absolute positioning to this wrapper, which only this component owns.
|
||||
*/
|
||||
outline?: ReactNode;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
// An upward-swipe intent immediately exits auto-follow; scrolling back near the bottom resumes it — see stream-follow.ts (#75) for the exact rule.
|
||||
@@ -268,7 +292,10 @@ export function MessageStream({
|
||||
return (
|
||||
<div className="relative h-full min-h-0">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
ref={(el) => {
|
||||
scrollRef.current = el;
|
||||
if (scrollElRef) scrollElRef.current = el;
|
||||
}}
|
||||
onScroll={onScroll}
|
||||
onWheel={(e) => {
|
||||
follow.wheel(e.deltaY);
|
||||
@@ -296,6 +323,7 @@ export function MessageStream({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{outline}
|
||||
{/* Back-to-bottom (shows once the user scrolls away from content below the fold): floats
|
||||
just above the composer; clicking returns to the bottom and re-enters follow, so the
|
||||
view keeps tracking the live stream. */}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Conversation outline data (pure logic, unit-testable): reduces the stream items to one
|
||||
* entry per exchange — the user's question plus a truncated plain-text preview of the
|
||||
* assistant's reply — for the left quick-jump index.
|
||||
*
|
||||
* Entry boundaries: a turn opens at a user prompt (user_text / user_image) and collects
|
||||
* every assistant_text that follows until the next prompt. Consecutive user items merge
|
||||
* into one entry (a prompt's text and images arrive as separate adjacent items — they are
|
||||
* one question, not several). Machine-only texts never open an entry: handoff /
|
||||
* model-switch source blocks render as banners with no user prose, and goal rounds past 1
|
||||
* are the loop re-sending an objective whose entry (round 1) is already collecting the
|
||||
* whole run's replies. Scheduled-trigger prompts DO open one — they are real turns worth
|
||||
* jumping to, unlike in input history (which only recalls what was typed here).
|
||||
* Steering messages ride inside a running turn and neither open an entry nor end one.
|
||||
*/
|
||||
import type { ChatItem } from "../../lib/omni/stream-model";
|
||||
import { parseUserMessageBody } from "./user-message-body";
|
||||
|
||||
export interface OutlineEntry {
|
||||
/** Stream item id of the turn's opening user message — the [data-outline-anchor] jump target. */
|
||||
anchorId: number;
|
||||
/** The user's question (protocol-stripped, trimmed); "" for an image/attachment-only prompt. */
|
||||
question: string;
|
||||
/** Plain accumulated assistant reply (capped — a preview source, not a transcript); "" while nothing arrived. */
|
||||
answer: string;
|
||||
}
|
||||
|
||||
/** Answer accumulation cap: enough for any preview length while keeping rebuilds O(entries) cheap. */
|
||||
const ANSWER_CAP = 500;
|
||||
|
||||
export function buildOutline(items: readonly ChatItem[]): OutlineEntry[] {
|
||||
const out: OutlineEntry[] = [];
|
||||
let current: OutlineEntry | null = null;
|
||||
let lastWasUser = false;
|
||||
for (const item of items) {
|
||||
if (item.kind === "user_text" || item.kind === "user_image") {
|
||||
let body = "";
|
||||
if (item.kind === "user_text") {
|
||||
const parsed = parseUserMessageBody(item.text);
|
||||
if (!parsed || (parsed.goalRound !== undefined && parsed.goalRound > 1)) {
|
||||
// A banner-only item is not a question, but it still separates user runs: a real
|
||||
// prompt right after it must open its own entry, not merge across the banner.
|
||||
lastWasUser = false;
|
||||
continue;
|
||||
}
|
||||
body = parsed.body;
|
||||
}
|
||||
if (lastWasUser && current) {
|
||||
// Same prompt, next fragment: only adopt a question if the entry has none yet
|
||||
// (text after images), never overwrite one.
|
||||
if (current.question === "" && body !== "") current.question = body;
|
||||
} else {
|
||||
current = { anchorId: item.id, question: body, answer: "" };
|
||||
out.push(current);
|
||||
}
|
||||
lastWasUser = true;
|
||||
continue;
|
||||
}
|
||||
lastWasUser = false;
|
||||
if (item.kind === "assistant_text" && current && current.answer.length < ANSWER_CAP) {
|
||||
const text = item.text.trim();
|
||||
if (text !== "") {
|
||||
current.answer = (current.answer === "" ? text : `${current.answer} ${text}`).slice(
|
||||
0,
|
||||
ANSWER_CAP,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders markdown-ish text down to a single truncated plain line for the entry previews:
|
||||
* fence lines drop (their code stays), images/links keep their label, block markers
|
||||
* (headings, quotes, list bullets) and emphasis characters strip, whitespace collapses.
|
||||
* Deliberately lossy — this feeds a one-line preview, not a renderer.
|
||||
*/
|
||||
export function previewText(md: string, max: number): string {
|
||||
let text = md
|
||||
.replace(/```[^\n]*/g, "")
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/^[ \t]*(?:#{1,6}[ \t]+|>[ \t]?|[-*+][ \t]+|\d+[.)][ \t]+)/gm, "")
|
||||
.replace(/[*_`~]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (text.length > max) text = `${text.slice(0, max).trimEnd()}…`;
|
||||
return text;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* icon + "Thinking" + elapsed time, click to expand the full thinking text. Shares the same row
|
||||
* style and set of running-state icons (in progress / done / failed) as tool cards.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { S } from "../../lib/strings";
|
||||
import { humanizeDuration } from "../../lib/format";
|
||||
import type { ThinkingItem } from "../../lib/omni/stream-model";
|
||||
@@ -15,6 +15,7 @@ import { Md } from "./md";
|
||||
|
||||
export function ThinkingBlock({ item }: { item: ThinkingItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const failed = item.stopReason !== undefined && item.stopReason !== "completed";
|
||||
const state: RunState = item.streaming ? "running" : failed ? "failed" : "done";
|
||||
const stateLabel = item.streaming
|
||||
@@ -24,12 +25,25 @@ export function ThinkingBlock({ item }: { item: ThinkingItem }) {
|
||||
: S.chat.workDone;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div ref={rootRef}>
|
||||
{/* Stacked sticky, second level: while this row's expanded body scrolls, the row pins
|
||||
right BELOW the stuck group header (top-4 = the header's -top-4 offset + its 2rem
|
||||
height) — the bar directly above the content is always the section the reader is
|
||||
in, never a skipped level; rows push each other out at their section boundaries.
|
||||
The background must be opaque for the stuck state (at rest it matches the card). */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onClick={() => {
|
||||
// Collapsing while the row is stuck: its real top sits above the fold — land the
|
||||
// view back on the row (nearest = no movement for expanding / in-view collapse).
|
||||
const willClose = open;
|
||||
setOpen((v) => !v);
|
||||
if (willClose) {
|
||||
requestAnimationFrame(() => rootRef.current?.scrollIntoView({ block: "nearest" }));
|
||||
}
|
||||
}}
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors duration-150 hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
className="sticky top-4 z-[4] flex w-full items-center gap-2 bg-white px-3 py-1.5 text-left transition-colors duration-150 hover:bg-gray-50 dark:bg-gray-900 dark:hover:bg-gray-800"
|
||||
>
|
||||
<StatusIcon state={state} label={stateLabel} />
|
||||
<span className="shrink-0 text-xs text-gray-500 dark:text-gray-400">{S.chat.thinking}</span>
|
||||
|
||||
@@ -203,6 +203,7 @@ function extractStringField(argsJson: string, field: string): PartialField | nul
|
||||
export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRenderContext }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const userToggled = useRef(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
// Matched by the current origin chain + toolCallId: prevents parent/child session tool_call_id collisions from lighting each other up.
|
||||
const pending = ctx.pendingApprovals.get(approvalKey(ctx.origin, item.toolCallId));
|
||||
|
||||
@@ -262,11 +263,17 @@ export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRen
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div ref={rootRef}>
|
||||
{/* Collapsed row: status icon + tool name + total duration (generation + execution,
|
||||
excluding approval wait) + the stop reason when the step did not finish cleanly.
|
||||
Expand chevron on the right.
|
||||
|
||||
Stacked sticky, second level (same as the thinking row): while this card's expanded
|
||||
output scrolls, the row pins right BELOW the stuck group header (top-4 = the
|
||||
header's -top-4 offset + its 2rem height) — the bar directly above the content is
|
||||
always the section the reader is in, never a skipped level. Opaque background for
|
||||
the stuck state; collapsing from stuck lands the view back on the row.
|
||||
|
||||
The stop reason used to render as a padded Badge pill, which competed for width on a
|
||||
phone. It is now the same plain `[reason]` marker the thinking row directly above it
|
||||
already uses (thinking-block.tsx) — cheap enough for 390px, and the two rows in a
|
||||
@@ -286,9 +293,13 @@ export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRen
|
||||
aria-expanded={open}
|
||||
onClick={() => {
|
||||
userToggled.current = true;
|
||||
const willClose = open;
|
||||
setOpen((v) => !v);
|
||||
if (willClose) {
|
||||
requestAnimationFrame(() => rootRef.current?.scrollIntoView({ block: "nearest" }));
|
||||
}
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors duration-150 hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
className="sticky top-4 z-[4] flex w-full items-center gap-2 bg-white px-3 py-1.5 text-left transition-colors duration-150 hover:bg-gray-50 dark:bg-gray-900 dark:hover:bg-gray-800"
|
||||
>
|
||||
<StatusIcon state={state} label={stateLabel} />
|
||||
<span className="shrink-0 truncate font-mono text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Shared "what did the user actually write" extraction for a user_text item, mirroring
|
||||
* MessageItem's parse chain (handoff / model-switch → goal → scheduled → skills →
|
||||
* attachment lines) without any rendering. Input history and the conversation outline both
|
||||
* need this reduction, and each re-implementing the chain would drift from the renderer the
|
||||
* moment a new protocol block is added — this module is the single non-rendering copy.
|
||||
*/
|
||||
import {
|
||||
parseHandoffMessage,
|
||||
parseModelSwitchMessage,
|
||||
parseScheduledMessage,
|
||||
} from "./agent-handoff";
|
||||
import { parseGoalMessage } from "./goal-use";
|
||||
import { parseSkillsMessage } from "./skill-use";
|
||||
import { splitAttachments } from "../../lib/attachments";
|
||||
|
||||
export interface UserMessageBody {
|
||||
/** What remains once protocol blocks and attachment lines are stripped: the text the user wrote (trimmed; may be empty for e.g. an image-only message). */
|
||||
body: string;
|
||||
/** Set when the message is a [goal] round re-send; round 1 carries the user's own objective, later rounds are the loop's re-injection. */
|
||||
goalRound?: number;
|
||||
/** True when the message was injected by a scheduled-task trigger rather than typed into the composer. */
|
||||
scheduled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a user_text item's raw text down to the user-authored body. Returns null for
|
||||
* machine-only source blocks ([handoff_from] / [model_switch_from]) — those render as
|
||||
* banners and carry no user prose at all.
|
||||
*/
|
||||
export function parseUserMessageBody(raw: string): UserMessageBody | null {
|
||||
if (parseHandoffMessage(raw) || parseModelSwitchMessage(raw)) return null;
|
||||
const goal = parseGoalMessage(raw);
|
||||
const afterGoal = goal ? goal.rest : raw;
|
||||
const scheduled = parseScheduledMessage(afterGoal);
|
||||
const afterScheduled = scheduled ? scheduled.rest : afterGoal;
|
||||
const skills = parseSkillsMessage(afterScheduled);
|
||||
const { text } = splitAttachments(skills ? skills.rest : afterScheduled);
|
||||
return {
|
||||
body: text.trim(),
|
||||
...(goal ? { goalRound: goal.round } : {}),
|
||||
scheduled: scheduled !== null,
|
||||
};
|
||||
}
|
||||
@@ -71,6 +71,7 @@ export function WorkGroup({
|
||||
const pending = hasPendingApproval(items, ctx);
|
||||
const [open, setOpen] = useState(isLast);
|
||||
const userToggled = useRef(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Before any manual toggle, follow "is last segment": expanded while in progress (last
|
||||
// segment), collapsed once pushed away from the end by later messages (turn finished).
|
||||
@@ -86,16 +87,49 @@ export function WorkGroup({
|
||||
const { steps, durationMs, startMs } = summarizeWork(items);
|
||||
|
||||
return (
|
||||
<div className="anim-msg my-2 overflow-hidden rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||
{/* Group header: a distinct title bar (solid background), on a separate layer from the step rows below it */}
|
||||
// overflow-clip (not overflow-hidden): the header below is position:sticky, and an
|
||||
// overflow-hidden ancestor is a scroll container — the header would then stick to this
|
||||
// card instead of the message list's scrollport, i.e. not stick at all. `clip` keeps
|
||||
// the exact same clipping (rounded corners included) without creating a scroll
|
||||
// container, and a sticky element never leaves its containing block, so the stuck
|
||||
// header itself is never clipped.
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="anim-msg my-2 overflow-clip rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
{/* Group header: a distinct title bar (solid background), on a separate layer from the
|
||||
step rows below it. Sticky against the message list's scrollport so a long expanded
|
||||
group can be collapsed from anywhere inside it — without this, finding the start of
|
||||
a long thinking/tool run means scrolling all the way back up. This is the FIRST of
|
||||
two stacked sticky levels: the currently scrolled thinking/tool row pins right
|
||||
below it at top-4 (see thinking-block.tsx / tool-call-card.tsx), so the bar
|
||||
directly above the content is always the section being read — the group header
|
||||
alone would skip a level. The background must stay fully opaque (the old dark
|
||||
900/60 read identically over the solid-900 card, but stuck over scrolling rows it
|
||||
would let them bleed through); z above the row level (z-[4]), below the stream's
|
||||
own overlays (back-to-bottom uses z-10). -top-4, not top-0: sticky offsets resolve
|
||||
against the scrollport INSIDE the scroll container's padding, so top-0 pins a py-4
|
||||
strip lower than the visible top and content scrolls through that gap; -top-4 is
|
||||
the same rem unit as the container's py-4, cancelling exactly at every font scale
|
||||
(the rows' top-4 = this offset plus the header's 2rem height, same reasoning). */}
|
||||
<button
|
||||
type="button"
|
||||
data-group-header
|
||||
aria-expanded={shown}
|
||||
onClick={() => {
|
||||
userToggled.current = true;
|
||||
// Collapsing while the header is stuck: the group's real top edge sits above the
|
||||
// fold, so after the body vanishes the viewport would land on unrelated content —
|
||||
// bring the (now header-only) group back into view once React commits. `nearest`
|
||||
// makes expanding and in-view collapsing a no-op. A forced-open pending approval
|
||||
// keeps the body (shown stays true), so that click moves nothing either.
|
||||
const willClose = open && !pending;
|
||||
setOpen((v) => !v);
|
||||
if (willClose) {
|
||||
requestAnimationFrame(() => rootRef.current?.scrollIntoView({ block: "nearest" }));
|
||||
}
|
||||
}}
|
||||
className="flex w-full items-center gap-2 bg-gray-50 px-3 py-2 text-left transition-colors duration-150 hover:bg-gray-100 dark:bg-gray-900/60 dark:hover:bg-gray-800/60"
|
||||
className="sticky -top-4 z-[5] flex w-full items-center gap-2 bg-gray-50 px-3 py-2 text-left transition-colors duration-150 hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-800"
|
||||
>
|
||||
<StatusIcon state={active ? "running" : "done"} size={12} />
|
||||
{/* The title doubles as status: "Running" while in progress, "Done" when finished. */}
|
||||
|
||||
Reference in New Issue
Block a user