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:
Yaowei Zheng
2026-08-03 21:02:33 +08:00
committed by GitHub
parent c4bf673309
commit 93c11e5557
16 changed files with 1322 additions and 15 deletions
+200
View File
@@ -0,0 +1,200 @@
/**
* Conversation minimap + sticky work-group header + composer input history (the three
* chat-navigation features), on a viewport whose stream gutter is wide enough for the
* tick rail (it measures the free margin live and hides itself when a docked panel eats
* the room — also asserted here).
*
* Flow: three exchanges in one session (the mock answers the first with
* thinking + exec_command and later ones with plain text — hasToolResult is history-wide),
* then a fresh session whose FIRST message is "slow stream test" for a 40-line tool output
* long enough to scroll inside.
*/
import { test, expect } from "@playwright/test";
import { provisionAndLogin } from "./auth.mjs";
const BASE = process.env.BASE_URL;
const MOCK = process.env.MOCK_URL;
const U = "outlineuser";
const P = "password123";
test.use({ viewport: { width: 1440, height: 860 } });
/** Reply completion marker: every mock turn-2 ends with this exact sentence. */
const REPLY = "Command finished";
test("minimap ticks + hover preview + jump, sticky group header, ArrowUp history recall", async ({
page,
}) => {
await provisionAndLogin(page.request, U, P);
const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
const projectId = projects.projects[0].projectId;
const put = await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
data: {
defaultModel: { provider: "custom", modelId: "claude-4-8" },
models: [
{
provider: "custom",
modelId: "claude-4-8",
apiKey: "sk-mock",
baseUrl: MOCK,
contextWindow: 200000,
},
],
},
});
expect(put.ok(), "put models").toBeTruthy();
const newSession = async () => {
const res = await (
await page.request.post(`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`, {
data: { provider: "custom", modelId: "claude-4-8", approvalMode: "allow-all" },
})
).json();
return res.session.sessionId;
};
const ta = page.getByPlaceholder(/输入消息/);
const send = async (text, replies) => {
await ta.click();
await ta.fill(text);
await page.keyboard.press("Enter");
await page.waitForFunction(
([marker, want]) => document.body.innerText.split(marker).length - 1 >= want,
[REPLY, replies],
{ timeout: 60000 },
);
};
// --- session 1: three exchanges -> outline entries, jump, scrollspy, history ---
await page.goto(`${BASE}/chat/${await newSession()}`);
await ta.waitFor();
await send("第一问:项目结构", 1);
await send("第二问:运行检查", 2);
await send("第三问:总结结果", 3);
// One tick per exchange in the gutter minimap; auto-follow parked the stream at the
// bottom, so the newest exchange is the active tick. Message text is NOT duplicated
// into the DOM at rest — the preview card exists only while hovering.
const ticks = page.locator("[data-outline-tick]");
const card = page.locator("[data-outline-card]");
await expect(ticks).toHaveCount(3);
// Park at the live bottom explicitly before asserting "bottom → newest tick active":
// that mapping is the semantic under test, not auto-follow's timing under load.
await page.evaluate(() => {
const c = document.querySelector("[data-outline-anchor]").closest(".overflow-y-auto");
c.scrollTop = c.scrollHeight;
});
await expect(page.locator("[data-outline-tick][aria-current]")).toHaveAttribute(
"aria-label",
/第 3 轮/,
);
await expect(card).toHaveCount(0);
// Hovering a tick pops the preview card (question bold + truncated reply); leaving unmounts it.
await ticks.first().hover();
await expect(card).toContainText("第一问:项目结构");
await expect(card).toContainText(REPLY);
await ta.hover();
await expect(card).toHaveCount(0);
// Clicking a tick jumps the stream to that turn and moves the active tick.
await ticks.first().click();
await expect(page.locator("[data-outline-tick][aria-current]")).toHaveAttribute(
"aria-label",
/第 1 轮/,
);
const jumpDelta = await page.evaluate(() => {
const container = document.querySelector("[data-outline-anchor]").closest(".overflow-y-auto");
const first = document.querySelector("[data-outline-anchor]");
return first.getBoundingClientRect().top - container.getBoundingClientRect().top;
});
expect(Math.abs(jumpDelta)).toBeLessThan(40);
// The rail lives in the free gutter: a docked panel that eats the slack hides it and
// the index moves to the toolbar dropdown (navigation stays reachable); closing the
// panel restores the rail (live measurement, not a breakpoint).
await page.getByRole("button", { name: "打开工作区" }).click();
await expect(ticks).toHaveCount(0);
const menuButton = page.getByRole("button", { name: "对话索引" });
await menuButton.click();
const menuEntries = page.locator("[data-outline-menu-entry]");
await expect(menuEntries).toHaveCount(3);
await expect(menuEntries.first()).toContainText("第一问:项目结构");
await menuEntries.nth(2).click(); // jump and close
await expect(menuEntries).toHaveCount(0);
await page.getByRole("button", { name: "打开工作区" }).click();
await expect(ticks).toHaveCount(3);
await expect(menuButton).toHaveCount(0);
// Phone-narrow: no rail either, the toolbar index instead; the agents-panel button
// drops to icon-only below sm (same rule as the workspace button).
await page.setViewportSize({ width: 420, height: 820 });
await expect(ticks).toHaveCount(0);
await expect(menuButton).toBeVisible();
await expect(page.getByText("智能体面板")).toBeHidden();
await page.setViewportSize({ width: 1440, height: 860 });
await expect(ticks).toHaveCount(3);
// ↑ walks back through this session's inputs, newest first; a second ↑ goes older.
await ta.click();
await page.keyboard.press("ArrowUp");
await expect(ta).toHaveValue("第三问:总结结果");
await page.keyboard.press("ArrowUp");
await expect(ta).toHaveValue("第二问:运行检查");
// ↓ walks forward and past the newest restores the (empty) draft.
await page.keyboard.press("ArrowDown");
await page.keyboard.press("ArrowDown");
await expect(ta).toHaveValue("");
// Editing a recalled entry ends navigation: ↑ then goes back to caret movement.
await page.keyboard.press("ArrowUp");
await ta.press("End");
await page.keyboard.type(",补充");
await page.keyboard.press("ArrowUp");
await expect(ta).toHaveValue("第三问:总结结果,补充");
await ta.fill("");
// --- session 2: long tool output -> sticky header ---
await page.goto(`${BASE}/chat/${await newSession()}`);
await ta.waitFor();
await send("slow stream test", 1);
// Expand the settled group, then the tool card with the 40-line output.
const header = page.locator("[data-group-header]");
await expect(header).toHaveCount(1);
await header.click();
await page.locator("button[aria-expanded]").filter({ hasText: "exec_command" }).first().click();
await expect(page.getByText("line 40")).toBeVisible();
// Scrolled into the middle of the tool output, the two sticky levels stack: the group
// header flush at the scrollport top (-top-4 cancels the container's own py-4), and the
// tool row pinned right below it (top-4 = the header's offset plus its height) — the bar
// directly above the content is the section being read, never a skipped level.
const stuck = await page.evaluate(() => {
const container = document.querySelector("[data-outline-anchor]").closest(".overflow-y-auto");
const head = document.querySelector("[data-group-header]");
const card = head.parentElement;
container.scrollTop = card.offsetTop + 400;
const ct = container.getBoundingClientRect().top;
const row = [...document.querySelectorAll("button[aria-expanded]")].find((b) =>
b.textContent.includes("exec_command"),
);
return {
delta: Math.abs(head.getBoundingClientRect().top - ct),
rowDelta: row.getBoundingClientRect().top - ct,
headerHeight: head.getBoundingClientRect().height,
cardAboveFold: card.getBoundingClientRect().top < ct,
};
});
expect(stuck.delta).toBeLessThan(2);
expect(Math.abs(stuck.rowDelta - stuck.headerHeight)).toBeLessThan(2); // stacked right below
expect(stuck.cardAboveFold).toBeTruthy();
// Collapsing from the stuck header lands the view back on the group, not on unrelated content.
await header.click();
const landed = await page.evaluate(() => {
const container = document.querySelector("[data-outline-anchor]").closest(".overflow-y-auto");
const card = document.querySelector("[data-group-header]").parentElement;
return card.getBoundingClientRect().top - container.getBoundingClientRect().top;
});
expect(landed).toBeGreaterThan(-5);
expect(landed).toBeLessThan(300);
});
@@ -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" &&
+55 -3
View File
@@ -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,
};
}
+37 -3
View File
@@ -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. */}
+8
View File
@@ -700,6 +700,14 @@ Scenarios:
statusCompacting: "Compacting",
pendingApprovals: (n: number) => `${n} pending approval${n > 1 ? "s" : ""}`,
jumpToLatest: "Jump to latest",
/** Conversation minimap (tick rail over the stream's left gutter): rail aria-label. */
outlineTitle: "Outline",
/** Tick accessible name: turn number + the question (or the no-text placeholder). */
outlineTickLabel: (n: number, question: string) => `Turn ${n}: ${question}`,
/** Entry label when the prompt had no text body (image / attachment-only message). */
outlineNoText: "(image or attachment)",
/** Answer-preview placeholder while the latest turn is still running with no reply text yet. */
outlineAnswering: "Answering…",
inputPlaceholder: "Type a message. Enter to send, Shift+Enter for newline, paste images",
inputPlaceholderShort: "Type a message…",
/** Placeholder while a Task is running (mid-run steering): the message is delivered between turns with the next request. */
+8
View File
@@ -683,6 +683,14 @@ Benchmark:
statusCompacting: "压缩中",
pendingApprovals: (n: number) => `${n} 个待审批`,
jumpToLatest: "回到最新消息",
/** Conversation minimap (tick rail over the stream's left gutter): rail aria-label. */
outlineTitle: "对话索引",
/** Tick accessible name: turn number + the question (or the no-text placeholder). */
outlineTickLabel: (n: number, question: string) => `第 ${n} 轮:${question}`,
/** Entry label when the prompt had no text body (image / attachment-only message). */
outlineNoText: "(图片或附件)",
/** Answer-preview placeholder while the latest turn is still running with no reply text yet. */
outlineAnswering: "回答生成中…",
inputPlaceholder: "输入消息,Enter 发送,Shift+Enter 换行,可粘贴图片",
inputPlaceholderShort: "输入消息…",
/** Placeholder while a Task is running (mid-run steering): the message is delivered between turns with the next request. */
+27
View File
@@ -262,6 +262,33 @@
.anim-msg {
animation: msg-in 180ms ease-out both;
}
/* Outline jump feedback: a soft brand wash fading out over the landed-on message. Applied by
conversation-outline.tsx to the [data-outline-anchor] wrapper via classList — deliberately
outside React's managed props, so stream re-renders don't strip it mid-flash. */
@keyframes outline-flash {
from {
background-color: var(--color-brand-50);
}
to {
background-color: transparent;
}
}
@keyframes outline-flash-dark {
from {
background-color: var(--color-brand-950);
}
to {
background-color: transparent;
}
}
.outline-flash {
border-radius: 0.5rem;
animation: outline-flash 900ms ease-out both;
}
.dark .outline-flash {
animation-name: outline-flash-dark;
}
/* Drawer slide-in. */
.anim-drawer-left {
animation: drawer-in-left 240ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
+114
View File
@@ -0,0 +1,114 @@
/**
* input-history.ts unit tests: which stream items become recallable entries, and the
* ↑/↓ navigation state machine the composer drives (start-from-empty, edit-ends-navigation,
* pin-at-oldest, stash restore past the newest).
*/
import { describe, expect, it } from "vitest";
import type { ChatItem } from "../src/lib/omni/stream-model";
import { buildSkillsMessage } from "../src/features/chat/skill-use";
import { handoffMessage } from "../src/features/chat/agent-handoff";
import { buildScheduledMessage } from "@prismshadow/penguin-core/markers";
import {
buildInputHistory,
caretOnFirstLine,
caretOnLastLine,
historyStepBack,
historyStepForward,
} from "../src/features/chat/input-history";
import type { HistoryNav } from "../src/features/chat/input-history";
let nextId = 0;
const user = (text: string): ChatItem => ({ kind: "user_text", id: nextId++, text });
const steering = (text: string): ChatItem => ({ kind: "user_steering", id: nextId++, text });
const assistant = (text: string): ChatItem => ({
kind: "assistant_text",
id: nextId++,
text,
streaming: false,
});
describe("buildInputHistory", () => {
it("collects typed prompts and steering in order, skipping machine-injected texts", () => {
const goalRound1 = `[goal]\nround: 1\nobjective: fix it\n[/goal]\nfix the bug`;
const goalRound2 = `[goal]\nround: 2\nobjective: fix it\n[/goal]\nfix the bug`;
const items: ChatItem[] = [
user("first question"),
assistant("answer"),
user(handoffMessage({ agentId: "agent-a", sessionId: "session-1", workspace: "/tmp/w" })),
user(buildScheduledMessage("nightly", "2026-08-01T00:00:00Z", "scheduled prompt")),
user(goalRound1),
user(goalRound2),
steering("please also check the tests"),
user(buildSkillsMessage(["my-skill"], "with a skill")),
user("first question"),
];
expect(buildInputHistory(items)).toEqual([
"first question",
"fix the bug",
"please also check the tests",
"with a skill",
"first question",
]);
});
it("collapses consecutive duplicates and drops empty bodies (image-only prompts)", () => {
const items: ChatItem[] = [user("same"), user("same"), user(" "), user("next")];
expect(buildInputHistory(items)).toEqual(["same", "next"]);
});
});
describe("history navigation", () => {
const history = ["one", "two", "three"];
it("starts from an empty draft at the newest entry and walks back, pinning at the oldest", () => {
const s1 = historyStepBack(history, null, "");
expect(s1?.text).toBe("three");
const s2 = historyStepBack(history, s1!.nav, "three");
expect(s2?.text).toBe("two");
const s3 = historyStepBack(history, s2!.nav, "two");
expect(s3?.text).toBe("one");
const s4 = historyStepBack(history, s3!.nav, "one");
expect(s4?.text).toBe("one"); // pinned: repeated ↑ at the oldest stays put
expect(s4?.nav?.index).toBe(0);
});
it("does not start from a non-empty draft, and stashes/restores the draft across a round trip", () => {
expect(historyStepBack(history, null, "half-written")).toBeNull();
const back = historyStepBack(history, null, " ");
expect(back?.nav?.stash).toBe(" ");
const fwd = historyStepForward(history, back!.nav, "three");
expect(fwd).toEqual({ nav: null, text: " " }); // past the newest: draft restored, navigation over
});
it("hands the keys back once the recalled text was edited", () => {
const nav: HistoryNav = { index: 2, stash: "", recalled: "three" };
expect(historyStepBack(history, nav, "three edited")).toBeNull();
expect(historyStepForward(history, nav, "three edited")).toBeNull();
});
it("steps forward through newer entries before restoring the stash", () => {
const nav: HistoryNav = { index: 0, stash: "draft", recalled: "one" };
const s1 = historyStepForward(history, nav, "one");
expect(s1?.text).toBe("two");
const s2 = historyStepForward(history, s1!.nav, "two");
expect(s2?.text).toBe("three");
const s3 = historyStepForward(history, s2!.nav, "three");
expect(s3).toEqual({ nav: null, text: "draft" });
});
it("never steps with an empty history", () => {
expect(historyStepBack([], null, "")).toBeNull();
});
});
describe("caret line guards", () => {
it("first/last line checks follow the newlines around the caret", () => {
const text = "line1\nline2";
expect(caretOnFirstLine(text, 3)).toBe(true);
expect(caretOnFirstLine(text, 8)).toBe(false);
expect(caretOnLastLine(text, 8)).toBe(true);
expect(caretOnLastLine(text, 3)).toBe(false);
expect(caretOnFirstLine("", 0)).toBe(true);
expect(caretOnLastLine("", 0)).toBe(true);
});
});
+104
View File
@@ -0,0 +1,104 @@
/**
* outline-model.ts unit tests: turn segmentation into outline entries (merge of adjacent
* user items, banner/goal-round handling, answer accumulation) and the plain-text preview
* reduction.
*/
import { describe, expect, it } from "vitest";
import type { ChatItem } from "../src/lib/omni/stream-model";
import { handoffMessage } from "../src/features/chat/agent-handoff";
import { buildScheduledMessage } from "@prismshadow/penguin-core/markers";
import { buildOutline, previewText } from "../src/features/chat/outline-model";
let nextId = 0;
const user = (text: string): ChatItem => ({ kind: "user_text", id: nextId++, text });
const image = (): ChatItem => ({ kind: "user_image", id: nextId++, imageUrl: "blob:x" });
const steering = (text: string): ChatItem => ({ kind: "user_steering", id: nextId++, text });
const assistant = (text: string, streaming = false): ChatItem => ({
kind: "assistant_text",
id: nextId++,
text,
streaming,
});
const stats = (): ChatItem => ({
kind: "task_stats",
id: nextId++,
stats: null,
assistantText: "",
});
describe("buildOutline", () => {
it("opens one entry per exchange and accumulates that turn's assistant text", () => {
const items: ChatItem[] = [
user("question A"),
assistant("part one."),
assistant("part two."),
stats(),
user("question B"),
assistant("reply B"),
];
const outline = buildOutline(items);
expect(outline).toHaveLength(2);
expect(outline[0]).toMatchObject({ question: "question A", answer: "part one. part two." });
expect(outline[1]).toMatchObject({ question: "question B", answer: "reply B" });
expect(outline[0]!.anchorId).toBe((items[0] as { id: number }).id);
});
it("merges adjacent user items into one entry and labels image-only prompts with an empty question", () => {
const items: ChatItem[] = [image(), user("text after images"), assistant("ok"), image()];
const outline = buildOutline(items);
expect(outline).toHaveLength(2);
expect(outline[0]).toMatchObject({ question: "text after images", answer: "ok" });
expect(outline[1]).toMatchObject({ question: "", answer: "" });
});
it("banner-only texts open no entry but separate user runs; steering stays inside the turn", () => {
const banner = handoffMessage({ agentId: "a", sessionId: "s", workspace: "/w" });
const items: ChatItem[] = [
user(banner),
user("real question"),
steering("mid-run note"),
assistant("answer"),
];
const outline = buildOutline(items);
expect(outline).toHaveLength(1);
expect(outline[0]).toMatchObject({ question: "real question", answer: "answer" });
});
it("keeps one entry per goal run (later rounds merge into round 1) and includes scheduled turns", () => {
const round = (n: number) => `[goal]\nround: ${n}\nobjective: o\n[/goal]\ndo the thing`;
const items: ChatItem[] = [
user(round(1)),
assistant("round 1 reply"),
user(round(2)),
assistant("round 2 reply"),
user(buildScheduledMessage("nightly", "2026-08-01T00:00:00Z", "scheduled prompt")),
assistant("scheduled reply"),
];
const outline = buildOutline(items);
expect(outline).toHaveLength(2);
expect(outline[0]).toMatchObject({
question: "do the thing",
answer: "round 1 reply round 2 reply",
});
expect(outline[1]).toMatchObject({ question: "scheduled prompt", answer: "scheduled reply" });
});
it("caps answer accumulation", () => {
const items: ChatItem[] = [user("q"), assistant("x".repeat(400)), assistant("y".repeat(400))];
const outline = buildOutline(items);
expect(outline[0]!.answer.length).toBeLessThanOrEqual(500);
});
});
describe("previewText", () => {
it("flattens markdown to one plain line", () => {
const md =
"# Title\n\nSome **bold** and `code`, a [link](https://x) and\n\n```ts\nconst a = 1\n```\n- item";
expect(previewText(md, 200)).toBe("Title Some bold and code, a link and const a = 1 item");
});
it("truncates with an ellipsis at the cap", () => {
expect(previewText("hello world", 5)).toBe("hello…");
expect(previewText("hello", 5)).toBe("hello");
});
});