diff --git a/packages/web/e2e/outline.spec.mjs b/packages/web/e2e/outline.spec.mjs new file mode 100644 index 0000000..56a6340 --- /dev/null +++ b/packages/web/e2e/outline.spec.mjs @@ -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); +}); diff --git a/packages/web/src/features/chat/chat-input.tsx b/packages/web/src/features/chat/chat-input.tsx index 78153d2..cf45cdb 100644 --- a/packages/web/src/features/chat/chat-input.tsx +++ b/packages/web/src/features/chat/chat-input.tsx @@ -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(null); + // Shell-style ↑/↓ history recall state (null = not navigating); ended by the text-mismatch effect below. + const historyNavRef = useRef(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) => { 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" && diff --git a/packages/web/src/features/chat/chat-page.tsx b/packages/web/src/features/chat/chat-page.tsx index 29495e9..a26c2b4 100644 --- a/packages/web/src/features/chat/chat-page.tsx +++ b/packages/web/src/features/chat/chat-page.tsx @@ -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(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() { - {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. */} + {S.chat.openAgents} {/* A pending approval inside a subagent: amber dot (the chip in the stream carries the accessible announcement). */} {anySubagentPending && ( @@ -1041,6 +1069,17 @@ export function ChatPage() { {S.chat.openWorkspace} + {/* 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 && ( + + )} + {/* Details popup: Model / Workspace / created time / stats */} )} - {/* 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). */}
{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={ + + } /> )}
diff --git a/packages/web/src/features/chat/conversation-outline.tsx b/packages/web/src/features/chat/conversation-outline.tsx new file mode 100644 index 0000000..4002514 --- /dev/null +++ b/packages/web/src/features/chat/conversation-outline.tsx @@ -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, + 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 | null { + const anchors = container.querySelectorAll("[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(`[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; + /** 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(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(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 | FocusEvent) => { + 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). + + ); +} + +/** + * 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; + running: boolean; +}) { + const [open, setOpen] = useState(false); + const [activeId, setActiveId] = useState(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 ( + 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" + > + + + } + > +
+ {entries.map((entry) => { + const answer = answerPreview(entry, entries, running, 80); + const active = entry.anchorId === activeId; + return ( + + ); + })} +
+
+ ); +} diff --git a/packages/web/src/features/chat/input-history.ts b/packages/web/src/features/chat/input-history.ts new file mode 100644 index 0000000..fa15b93 --- /dev/null +++ b/packages/web/src/features/chat/input-history.ts @@ -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"); +} diff --git a/packages/web/src/features/chat/message-stream.tsx b/packages/web/src/features/chat/message-stream.tsx index 9f19ab8..8b2019b 100644 --- a/packages/web/src/features/chat/message-stream.tsx +++ b/packages/web/src/features/chat/message-stream.tsx @@ -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 ? ( +
+ {renderSeg(seg, i)} +
+ ) : ( + 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; + /** + * 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(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 (
{ + scrollRef.current = el; + if (scrollElRef) scrollElRef.current = el; + }} onScroll={onScroll} onWheel={(e) => { follow.wheel(e.deltaY); @@ -296,6 +323,7 @@ export function MessageStream({ )}
+ {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. */} diff --git a/packages/web/src/features/chat/outline-model.ts b/packages/web/src/features/chat/outline-model.ts new file mode 100644 index 0000000..601983f --- /dev/null +++ b/packages/web/src/features/chat/outline-model.ts @@ -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; +} diff --git a/packages/web/src/features/chat/thinking-block.tsx b/packages/web/src/features/chat/thinking-block.tsx index 91c3955..001ab01 100644 --- a/packages/web/src/features/chat/thinking-block.tsx +++ b/packages/web/src/features/chat/thinking-block.tsx @@ -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(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 ( -
+
+ {/* 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). */}