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
+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");
});
});