23a39dab15
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
153 lines
6.8 KiB
TypeScript
153 lines
6.8 KiB
TypeScript
/**
|
|
* 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. Also owns the outline's display
|
|
* math: the minimum-turns gate both shapes share and the tick rail's sliding window.
|
|
*
|
|
* 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;
|
|
|
|
/**
|
|
* Visibility gate shared by both outline shapes (tick rail and toolbar dropdown): below
|
|
* this many turns the whole conversation is a flick of the wheel away, and an index would
|
|
* be chrome without navigation value.
|
|
*/
|
|
export const OUTLINE_MIN_TURNS = 5;
|
|
|
|
/** Rail window half-widths: at most this many ticks render before/after the active one. */
|
|
export const OUTLINE_WINDOW_BEFORE = 20;
|
|
export const OUTLINE_WINDOW_AFTER = 20;
|
|
|
|
/**
|
|
* The slice of entries the tick rail renders: a sliding window of at most
|
|
* `before + 1 + after` ticks kept centered on the active entry, shifted — never shrunk —
|
|
* at the edges (at the bottom of a long conversation the window is simply the last
|
|
* `before + 1 + after` turns). No active entry yet (null / -1) parks the window at the
|
|
* END, where a conversation opens and where new turns appear. Bounds are indices into the
|
|
* full entries array (`end` exclusive): callers slice with them and must keep labeling
|
|
* ticks by GLOBAL index — the window moves which ticks exist, never what they are.
|
|
*/
|
|
export function windowOutline(
|
|
entryCount: number,
|
|
activeIndex: number | null,
|
|
before: number = OUTLINE_WINDOW_BEFORE,
|
|
after: number = OUTLINE_WINDOW_AFTER,
|
|
): { start: number; end: number } {
|
|
const size = before + 1 + after;
|
|
if (entryCount <= size) return { start: 0, end: entryCount };
|
|
if (activeIndex === null || activeIndex < 0 || activeIndex >= entryCount) {
|
|
return { start: entryCount - size, end: entryCount };
|
|
}
|
|
const start = Math.min(Math.max(0, activeIndex - before), entryCount - size);
|
|
return { start, end: start + size };
|
|
}
|
|
|
|
/** Tick pitch bounds (px): compress toward MIN as the window outgrows the rail, never past hoverability. */
|
|
export const TICK_PITCH_MAX = 12;
|
|
export const TICK_PITCH_MIN = 5;
|
|
|
|
/** Vertical allowance (px) the tick stack keeps free inside the rail: the edge dots plus breathing room. */
|
|
const RAIL_STACK_ALLOWANCE = 48;
|
|
|
|
/** Tick pitch (px) for `count` ticks in a `height`-px rail: MAX, compressed toward MIN as the stack outgrows it. */
|
|
export function railTickPitch(height: number, count: number): number {
|
|
return Math.max(
|
|
TICK_PITCH_MIN,
|
|
Math.min(TICK_PITCH_MAX, Math.floor((height - RAIL_STACK_ALLOWANCE) / Math.max(1, count))),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Height-adaptive window half-width: at most OUTLINE_WINDOW_BEFORE/AFTER, shrunk until
|
|
* the whole window fits the measured rail at minimum pitch. A short rail (small window,
|
|
* split screen) thus shows fewer turns instead of letting the tick stack spill over the
|
|
* toolbar and composer. One symmetric value, since the default half-widths are equal.
|
|
*/
|
|
export function railWindowHalf(height: number): number {
|
|
const fits = Math.floor((height - RAIL_STACK_ALLOWANCE) / TICK_PITCH_MIN);
|
|
return Math.min(OUTLINE_WINDOW_BEFORE, Math.max(0, Math.floor((fits - 1) / 2)));
|
|
}
|
|
|
|
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;
|
|
}
|