@@ -389,7 +425,11 @@ function emptyEntriesSpec({
}
if (messagesLoading) {
return {
- content:
{loadingText}
,
+ content: (
+
+ {loadingText}
+
+ ),
interactive: false,
};
}
@@ -448,6 +488,7 @@ export function overflowsPanel(
function usePanelContentScrollable(
scrollRef: RefObject
,
contentRef: RefObject,
+ onGeometryChange: () => void,
): boolean {
const [isScrollable, setIsScrollable] = useState(false);
const measure = useCallback(() => {
@@ -463,7 +504,10 @@ function usePanelContentScrollable(
setIsScrollable(measure());
const scroller = scrollRef.current;
if (!scroller) return;
- const observer = new ResizeObserver(() => setIsScrollable(measure()));
+ const observer = new ResizeObserver(() => {
+ setIsScrollable(measure());
+ onGeometryChange();
+ });
observer.observe(scroller);
return () => observer.disconnect();
});
@@ -505,164 +549,3 @@ function useLoadingGrace(sessionId: string | null, isLoadingMore: boolean): bool
}, [sessionId, isLoadingMore, grace.sessionId]);
return grace.sessionId === sessionId && grace.show;
}
-
-/** The small `#N` ordinal label at the start of a prompt bubble. Rendered
- * only when the entry carries a known ordinal; `#N` is not translatable copy
- * (precedent: `#${pr.pr_number}`). */
-function PromptNumberLabel({ index, promptNumber }: { index: number; promptNumber: number }) {
- return (
-
- #{promptNumber}
-
- );
-}
-
-type PromptHistoryRowProps = {
- sessionId: string | null;
- entry: PromptHistoryEntry;
- index: number;
- expanded: boolean;
- maxHeight: string;
- onToggle: () => void;
- onNavigate?: (messageId: string) => void;
-};
-
-/** One prompt-history row: the prompt text (truncated, or expanded into a
- * scrollable box) inside the transcript-style bubble, its relative time and
- * duration. The bubble is directly clickable for transcript navigation; the
- * expand/collapse chevron floats over the truncated text's ellipsis when it
- * overflows. */
-function PromptHistoryRow({
- sessionId,
- entry,
- index,
- expanded,
- maxHeight,
- onToggle,
- onNavigate,
-}: PromptHistoryRowProps) {
- const { t } = useTranslation();
- const { isFavorite } = useMessageFavorite(sessionId ?? "", entry.messageId);
- const textRef = useRef(null);
- const [overflow, setOverflow] = useState(false);
- useEffect(() => {
- const text = textRef.current;
- if (!text) return;
- /** Recomputes whether the prompt text overflows its single-line span. */
- const update = () => setOverflow(text.scrollWidth > text.clientWidth);
- update();
- const observer = new ResizeObserver(update);
- observer.observe(text);
- return () => observer.disconnect();
- }, []);
- const showToggle = overflow || expanded;
- return (
-
-
- {/* Same bubble as the transcript's user message: markdown-body
- font, rounded-2xl, blue when not favorited / yellow when the
- message is starred — with lighter padding. The prompt bubble is
- directly clickable for transcript navigation. */}
-
{
- if (event.target instanceof HTMLElement && event.target.closest("button")) return;
- onNavigate?.(entry.messageId);
- }}
- onKeyDown={(event) => {
- if (event.key !== "Enter" && event.key !== " ") return;
- if (event.target instanceof HTMLElement && event.target.closest("button")) return;
- event.preventDefault();
- onNavigate?.(entry.messageId);
- }}
- >
- {entry.promptNumber !== null && (
-
- )}
- {entry.isAgentPrompt && (
-
- )}
-
- {entry.content}
-
- {expanded && (
-
- {entry.content}
-
- )}
- {showToggle && (
-
- {expanded ? : }
-
- )}
-
-
-
-
-
- {formatRelativeCompact(entry.sentAt)}
-
-
-
-
- );
-}
-
-/** Renders the formatted duration label for a prompt row, or null when the
- * entry has no recorded duration. The hourglass marks it as elapsed time
- * and matches the surrounding text size. */
-function PromptDuration({
- durationSeconds,
- index,
-}: {
- durationSeconds: number | null;
- index: number;
-}) {
- const { t } = useTranslation();
- if (durationSeconds === null) return null;
- return (
-
-
- {formatPromptDuration(durationSeconds, {
- s: t("task:durationUnitSeconds"),
- m: t("task:durationUnitMinutes"),
- h: t("task:durationUnitHours"),
- })}
-
- );
-}
diff --git a/apps/web/components/task/prompt-history-panel-row.tsx b/apps/web/components/task/prompt-history-panel-row.tsx
new file mode 100644
index 0000000000..7f72a598ea
--- /dev/null
+++ b/apps/web/components/task/prompt-history-panel-row.tsx
@@ -0,0 +1,262 @@
+import { useLayoutEffect, useRef, useState, type RefObject } from "react";
+import { useTranslation } from "react-i18next";
+import { Button } from "@kandev/ui/button";
+import {
+ IconChevronDown,
+ IconChevronUp,
+ IconClock,
+ IconHourglass,
+ IconRobot,
+} from "@tabler/icons-react";
+import { useMessageFavorite } from "@/hooks/domains/session/use-message-favorite";
+import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
+import { formatDateTime, formatRelativeCompact } from "@/lib/i18n/formats";
+import { formatPromptDuration, type PromptHistoryEntry } from "@/lib/prompt-history";
+import { cn } from "@/lib/utils";
+import { PromptMentionText } from "./chat/messages/prompt-mention-components";
+
+export type PromptHistoryRowProps = {
+ sessionId: string | null;
+ entry: PromptHistoryEntry;
+ index: number;
+ promptNames: string[];
+ expanded: boolean;
+ maxHeight: string;
+ onToggle: () => void;
+ onNavigate?: (messageId: string) => void;
+};
+
+function isNestedInteractiveTarget(target: EventTarget | null, currentTarget: Element) {
+ const interactiveTarget =
+ target instanceof Element ? target.closest('button,[role="button"]') : null;
+ return interactiveTarget !== null && interactiveTarget !== currentTarget;
+}
+
+function PromptNumberLabel({ index, promptNumber }: { index: number; promptNumber: number }) {
+ return (
+
+ #{promptNumber}
+
+ );
+}
+
+function usePromptHistoryOverflow(
+ textRef: RefObject,
+ content: string,
+ promptNames: PromptHistoryRowProps["promptNames"],
+ expanded: boolean,
+) {
+ const [overflow, setOverflow] = useState(false);
+ useLayoutEffect(() => {
+ const text = textRef.current;
+ if (!text) return;
+ const update = () => setOverflow(text.scrollWidth > text.clientWidth);
+ update();
+ const observer = new ResizeObserver(update);
+ observer.observe(text);
+ return () => observer.disconnect();
+ }, [textRef, content, promptNames, expanded]);
+ return overflow;
+}
+
+/** One prompt-history row with transcript navigation and prompt expansion. */
+export function PromptHistoryRow({
+ sessionId,
+ entry,
+ index,
+ promptNames,
+ expanded,
+ maxHeight,
+ onToggle,
+ onNavigate,
+}: PromptHistoryRowProps) {
+ const { t } = useTranslation();
+ const rowLabelId = `prompt-history-label-${entry.messageId}`;
+ const rowLabel =
+ entry.promptNumber == null
+ ? t("task:promptHistoryPromptLabelGeneric")
+ : t("task:promptHistoryPromptLabel", { number: entry.promptNumber });
+ return (
+
+
+ {rowLabel}
+
+
+
+
+
+ {formatRelativeCompact(entry.sentAt)}
+
+
+
+
+ );
+}
+
+function PromptHistoryExpandButton({
+ index,
+ expanded,
+ onToggle,
+ labelId,
+}: {
+ index: number;
+ expanded: boolean;
+ onToggle: () => void;
+ labelId: string;
+}) {
+ const { isFinePointer } = useResponsiveBreakpoint();
+ const { t } = useTranslation();
+ return (
+ {
+ event.stopPropagation();
+ onToggle();
+ }}
+ >
+ {expanded ? : }
+
+ );
+}
+
+function PromptHistoryBubble({
+ sessionId,
+ entry,
+ index,
+ promptNames,
+ expanded,
+ maxHeight,
+ onToggle,
+ onNavigate,
+ rowLabelId,
+ rowLabel,
+}: PromptHistoryRowProps & { rowLabelId: string; rowLabel: string }) {
+ const { isFavorite } = useMessageFavorite(sessionId ?? "", entry.messageId);
+ const textRef = useRef(null);
+ const overflow = usePromptHistoryOverflow(textRef, entry.content, promptNames, expanded);
+ const showToggle = overflow || expanded;
+ return (
+
+
{
+ if (isNestedInteractiveTarget(event.target, event.currentTarget)) return;
+ onNavigate?.(entry.messageId);
+ }}
+ >
+ {entry.promptNumber !== null && (
+
+ )}
+ {entry.isAgentPrompt && (
+
+ )}
+
+
+
+ {expanded && (
+
+ )}
+ {showToggle && (
+
+ )}
+
+ {onNavigate && (
+
onNavigate(entry.messageId)}
+ onKeyDown={(event) => {
+ if (event.key !== "Enter" && event.key !== " ") return;
+ event.preventDefault();
+ onNavigate(entry.messageId);
+ }}
+ />
+ )}
+
+ );
+}
+
+function PromptDuration({
+ durationSeconds,
+ index,
+}: {
+ durationSeconds: number | null;
+ index: number;
+}) {
+ const { t } = useTranslation();
+ if (durationSeconds === null) return null;
+ return (
+
+
+ {formatPromptDuration(durationSeconds, {
+ s: t("task:durationUnitSeconds"),
+ m: t("task:durationUnitMinutes"),
+ h: t("task:durationUnitHours"),
+ })}
+
+ );
+}
diff --git a/apps/web/components/task/sessions-dropdown.tsx b/apps/web/components/task/sessions-dropdown.tsx
index f89bb27d8e..3392a8305c 100644
--- a/apps/web/components/task/sessions-dropdown.tsx
+++ b/apps/web/components/task/sessions-dropdown.tsx
@@ -275,7 +275,7 @@ export const SessionsDropdown = memo(function SessionsDropdown({
diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx
index acc9636e85..1046fba94d 100644
--- a/apps/web/components/task/task-chat-panel.tsx
+++ b/apps/web/components/task/task-chat-panel.tsx
@@ -1087,10 +1087,10 @@ export const TaskChatPanel = memo(function TaskChatPanel({
const showAnchoredPromptBar = useAppStore((state) => state.userSettings.showAnchoredPromptBar);
const showScrollToLastPrompt = useAppStore((state) => state.userSettings.showScrollToLastPrompt);
const showScrollToStart = useAppStore((state) => state.userSettings.showScrollToStart);
- const { isMobile } = useResponsiveBreakpoint();
- // The anchored bar is a desktop-only, opt-in affordance; mobile always
- // falls back to the scroll button.
- const showAnchoredBar = !isMobile && showAnchoredPromptBar;
+ const { isMobile, isFinePointer } = useResponsiveBreakpoint();
+ // The anchored bar is a desktop-only, fine-pointer affordance; coarse
+ // pointers use the compact scroll control instead.
+ const showAnchoredBar = isFinePointer && !isMobile && showAnchoredPromptBar;
const [anchoredBarHeight, setAnchoredBarHeight] = useState(0);
const { anchoredBarVisible, scrollButtonEligible, scrollDirection } =
resolveLastPromptControls(lastPromptEdge);
diff --git a/apps/web/e2e/tests/chat/last-prompt-scroll.spec.ts b/apps/web/e2e/tests/chat/last-prompt-scroll.spec.ts
index f07541ec79..48a26880cb 100644
--- a/apps/web/e2e/tests/chat/last-prompt-scroll.spec.ts
+++ b/apps/web/e2e/tests/chat/last-prompt-scroll.spec.ts
@@ -12,8 +12,19 @@ import {
seedScrolledPastLastPrompt,
} from "./last-prompt-scroll-helpers";
+const ANCHORED_PROMPT_NAME = "e2e-anchored-template";
+const ANCHORED_PROMPT_CONTENT = "Pinned prompt preview content.";
+
+const PROMPT_MENTION_CHIP = "custom-prompt-mention";
+
test.describe("@chat last prompt scroll affordance", () => {
test.afterEach(async ({ apiClient }) => {
+ const { prompts } = await apiClient.listPrompts();
+ for (const prompt of prompts) {
+ if (!prompt.builtin && prompt.name === ANCHORED_PROMPT_NAME) {
+ await apiClient.deletePrompt(prompt.id).catch(() => undefined);
+ }
+ }
// The anchored-bar test flips this setting; restore the default so later
// tests in this worker see it again.
await apiClient.saveUserSettings({
@@ -241,7 +252,8 @@ test.describe("@chat last prompt scroll affordance", () => {
seedData,
}) => {
test.setTimeout(90_000);
- const richPrompt = `${LAST_PROMPT_MARKER}\n\nRun \`terraform apply\` after review.`;
+ await apiClient.createPrompt(ANCHORED_PROMPT_NAME, ANCHORED_PROMPT_CONTENT);
+ const richPrompt = `${LAST_PROMPT_MARKER} @${ANCHORED_PROMPT_NAME}\n\nRun \`terraform apply\` after review.`;
await apiClient.saveUserSettings({ show_anchored_prompt_bar: true });
const session = await seedScrolledPastLastPrompt(
testPage,
@@ -254,6 +266,12 @@ test.describe("@chat last prompt scroll affordance", () => {
await expect(bar).toHaveAttribute("data-state", "open", { timeout: 10_000 });
await expect(bar.locator("code")).toHaveText("terraform apply");
+ const chip = bar.getByTestId(PROMPT_MENTION_CHIP);
+ await expect(chip).toHaveAttribute("data-prompt-name", ANCHORED_PROMPT_NAME);
+ await chip.hover();
+ await expect(testPage.getByText(ANCHORED_PROMPT_CONTENT, { exact: false })).toBeVisible({
+ timeout: 10_000,
+ });
});
test("anchored bar stays closed while the last prompt is partially visible", async ({
diff --git a/apps/web/e2e/tests/task/mobile-plan-formatting-toolbar.spec.ts b/apps/web/e2e/tests/task/mobile-plan-formatting-toolbar.spec.ts
index ccb39925d3..3500d1e3ac 100644
--- a/apps/web/e2e/tests/task/mobile-plan-formatting-toolbar.spec.ts
+++ b/apps/web/e2e/tests/task/mobile-plan-formatting-toolbar.spec.ts
@@ -38,7 +38,7 @@ async function seedMobilePlan(
await expect.poll(() => apiClient.getTaskPlan(task.id), { timeout: 30_000 }).not.toBeNull();
await session.waitForChatIdle({ timeout: 45_000 });
await session.togglePlanMode();
- await testPage.getByRole("button", { name: "Plan", exact: true }).tap();
+ await testPage.getByRole("navigation").getByRole("button", { name: "Plan", exact: true }).tap();
await expect(session.planPanel).toBeVisible({ timeout: 10_000 });
await expect(session.planPanel).toContainText(PLAN_TEXT, { timeout: 15_000 });
return session;
diff --git a/apps/web/e2e/tests/task/mobile-plan-restore.spec.ts b/apps/web/e2e/tests/task/mobile-plan-restore.spec.ts
index 79f13d25b4..64d2ce1e28 100644
--- a/apps/web/e2e/tests/task/mobile-plan-restore.spec.ts
+++ b/apps/web/e2e/tests/task/mobile-plan-restore.spec.ts
@@ -45,7 +45,7 @@ async function seedMobilePlanTask(
await session.waitForChatIdle({ timeout: 45_000 });
await session.togglePlanMode();
- await testPage.getByRole("button", { name: "Plan", exact: true }).tap();
+ await testPage.getByRole("navigation").getByRole("button", { name: "Plan", exact: true }).tap();
await expect(session.planPanel).toBeVisible({ timeout: 10_000 });
await expect(session.planPanel).toContainText("Mobile draft B", { timeout: 15_000 });
return session;
diff --git a/apps/web/e2e/tests/task/mobile-plan-size-ceiling.spec.ts b/apps/web/e2e/tests/task/mobile-plan-size-ceiling.spec.ts
index 8d3f5202de..b6beba236d 100644
--- a/apps/web/e2e/tests/task/mobile-plan-size-ceiling.spec.ts
+++ b/apps/web/e2e/tests/task/mobile-plan-size-ceiling.spec.ts
@@ -10,20 +10,24 @@ const SUBMITTED_BYTES = 300_000;
const MARKER = "MOBILE-PLAN-SIZE-CEILING-MARKER";
function contentOfByteLength(totalBytes: number): string {
- return `${MARKER}${"A".repeat(totalBytes - MARKER.length)}`;
+ const markerBytes = new TextEncoder().encode(MARKER).byteLength;
+ const fillerBytes = totalBytes - markerBytes;
+ return `${MARKER}${"€".repeat(Math.ceil(fillerBytes / 3))}`;
}
async function pasteIntoPlanEditor(session: SessionPage, content: string) {
const editor = session.planEditor();
- // Use a browser-level click before dispatching the paste. The click is
- // supported by touch emulation and the ProseMirror paste handler then sees
- // the same event as a user paste without typing 300,000 characters.
- await editor.click();
+ // Dispatch a browser paste event so ProseMirror processes the text through
+ // its normal clipboard path.
await editor.evaluate((element, pasted) => {
+ element.focus();
const clipboardData = new DataTransfer();
clipboardData.setData("text/plain", pasted);
- const pasteEvent = new Event("paste", { bubbles: true, cancelable: true });
- Object.defineProperty(pasteEvent, "clipboardData", { value: clipboardData });
+ const pasteEvent = new ClipboardEvent("paste", {
+ bubbles: true,
+ cancelable: true,
+ clipboardData,
+ });
element.dispatchEvent(pasteEvent);
}, content);
}
@@ -69,7 +73,7 @@ test.describe("mobile: plan content size ceiling", () => {
await session.waitForChatIdle({ timeout: 45_000 });
await session.togglePlanMode();
- await testPage.getByRole("button", { name: "Plan", exact: true }).tap();
+ await testPage.getByRole("navigation").getByRole("button", { name: "Plan", exact: true }).tap();
await expect(session.planPanel).toBeVisible({ timeout: 10_000 });
const rejectedCreate = wsWatcher.waitForResponse("task.plan.create", { timeout: 30_000 });
diff --git a/apps/web/e2e/tests/task/mobile-plan-table-resize.spec.ts b/apps/web/e2e/tests/task/mobile-plan-table-resize.spec.ts
index 5e33675ac6..30b391d4c4 100644
--- a/apps/web/e2e/tests/task/mobile-plan-table-resize.spec.ts
+++ b/apps/web/e2e/tests/task/mobile-plan-table-resize.spec.ts
@@ -39,7 +39,7 @@ test.describe("mobile: Plan table column resizing", () => {
await session.waitForChatIdle({ timeout: 45_000 });
await session.composerReady();
await session.togglePlanMode();
- await testPage.getByRole("button", { name: "Plan", exact: true }).tap();
+ await testPage.getByRole("navigation").getByRole("button", { name: "Plan", exact: true }).tap();
await expect(session.planPanel).toBeVisible({ timeout: 10_000 });
const editor = session.planEditor();
diff --git a/apps/web/e2e/tests/task/mobile-plan-toolbar-implement.spec.ts b/apps/web/e2e/tests/task/mobile-plan-toolbar-implement.spec.ts
index 7dba82e8fe..2fc1ad28ad 100644
--- a/apps/web/e2e/tests/task/mobile-plan-toolbar-implement.spec.ts
+++ b/apps/web/e2e/tests/task/mobile-plan-toolbar-implement.spec.ts
@@ -51,7 +51,7 @@ async function seedMobileTaskWithPlan(testPage: Page, apiClient: ApiClient, seed
async function openMobilePlanPanel(testPage: Page, session: SessionPage) {
await session.togglePlanMode();
- await testPage.getByRole("button", { name: "Plan", exact: true }).tap();
+ await testPage.getByRole("navigation").getByRole("button", { name: "Plan", exact: true }).tap();
await expect(session.planPanel).toBeVisible({ timeout: 10_000 });
}
diff --git a/apps/web/e2e/tests/task/mobile-prompt-history-panel.spec.ts b/apps/web/e2e/tests/task/mobile-prompt-history-panel.spec.ts
index 37c2593e6b..3afddd72f4 100644
--- a/apps/web/e2e/tests/task/mobile-prompt-history-panel.spec.ts
+++ b/apps/web/e2e/tests/task/mobile-prompt-history-panel.spec.ts
@@ -8,7 +8,6 @@ import {
SECOND_PROMPT_MARKER,
seedLongPromptHistory,
} from "../../helpers/prompt-history-long-seed";
-
async function installTargetScrollCounter(
page: Page,
messageId: string,
@@ -72,6 +71,7 @@ async function installTargetScrollCounter(
);
}
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
+const MOBILE_ALIAS = "mobile-history-alias";
/** CDP touch-scrolls the element upward, which scrolls its content DOWN
* (revealing older content), matching the repo's mobile scroll convention. */
@@ -114,6 +114,14 @@ async function revealPromptHistoryTarget(page: Page, panel: Locator, targetBubbl
}
test.describe("Prompt history panel on mobile", () => {
+ test.afterEach(async ({ apiClient }) => {
+ const { prompts } = await apiClient.listPrompts();
+ for (const prompt of prompts) {
+ if (!prompt.builtin && prompt.name === MOBILE_ALIAS) {
+ await apiClient.deletePrompt(prompt.id).catch(() => undefined);
+ }
+ }
+ });
test("opens from Panels and returns to Chat for a prompt jump", async ({
testPage,
apiClient,
@@ -121,7 +129,8 @@ test.describe("Prompt history panel on mobile", () => {
}) => {
test.setTimeout(90_000);
- const seedPrompt = "Mobile prompt history seeded prompt";
+ await apiClient.createPrompt(MOBILE_ALIAS, "Mobile history alias content");
+ const seedPrompt = `@${MOBILE_ALIAS}`;
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile prompt history task",
@@ -169,13 +178,21 @@ test.describe("Prompt history panel on mobile", () => {
await expect(historyPanel).toBeVisible({ timeout: 10_000 });
const row = testPage.getByTestId("prompt-history-row-0");
await expect(row).toContainText(seedPrompt);
+ const chip = row.getByTestId("custom-prompt-mention");
+ await expect(chip).toBeVisible({ timeout: 15_000 });
+ await expect(chip).toHaveAttribute("data-prompt-name", MOBILE_ALIAS);
+ await chip.tap();
+ await expect(testPage.getByText("Mobile history alias content")).toBeVisible();
+ await testPage.keyboard.press("Escape");
+ await expect(testPage.getByText("Mobile history alias content")).toHaveCount(0);
// The single seeded prompt is the session's very first: #1.
await expect(row.getByTestId("prompt-history-number-0")).toHaveText("#1");
- const prompt = row.locator('[role="button"]').first();
+ const prompt = row.locator("[data-message-id]");
const promptBox = await prompt.boundingBox();
expect(promptBox?.height).toBeGreaterThanOrEqual(44);
- await prompt.tap();
+ // Tap the row padding, not the nested alias chip.
+ await prompt.tap({ position: { x: 4, y: 4 } });
await expect(testPage.locator(`#msg-${promptMessage.id}`)).toBeAttached();
});
@@ -279,7 +296,7 @@ test.describe("Prompt history panel on mobile", () => {
.getAttribute("data-message-id");
if (!middlePromptMessageId) throw new Error("Middle prompt row has no message id");
await installTargetScrollCounter(testPage, middlePromptMessageId, true);
- await middleRow.locator('[role="button"]').first().tap();
+ await middleRow.locator("[data-message-id]").tap();
await expect.poll(() => aroundRequests, { timeout: 10_000 }).toBe(1);
const targetMessage = new SessionPage(testPage)
.activeChat()
diff --git a/apps/web/e2e/tests/task/plan-size-ceiling.spec.ts b/apps/web/e2e/tests/task/plan-size-ceiling.spec.ts
index 92c8fee076..71f375554c 100644
--- a/apps/web/e2e/tests/task/plan-size-ceiling.spec.ts
+++ b/apps/web/e2e/tests/task/plan-size-ceiling.spec.ts
@@ -24,10 +24,11 @@ const CEILING_BYTES = 262_144; // planinjection ceiling; kept in sync with plan_
const MARKER = "PLAN-SIZE-CEILING-MARKER";
const SUBMITTED_BYTES = 300_000;
-/** ASCII filler so content.length (UTF-16 code units) equals UTF-8 byte length exactly. */
+/** UTF-8 filler keeps the oversized payload compact enough for browser E2E. */
function contentOfByteLength(totalBytes: number): string {
- const filler = "A".repeat(totalBytes - MARKER.length);
- return `${MARKER}${filler}`;
+ const markerBytes = new TextEncoder().encode(MARKER).byteLength;
+ const fillerBytes = totalBytes - markerBytes;
+ return `${MARKER}${"€".repeat(Math.ceil(fillerBytes / 3))}`;
}
/** Dispatch a synthetic plain-text paste into the ProseMirror plan editor.
diff --git a/apps/web/e2e/tests/task/prompt-history-panel.spec.ts b/apps/web/e2e/tests/task/prompt-history-panel.spec.ts
index b3f893b170..baf4a13c11 100644
--- a/apps/web/e2e/tests/task/prompt-history-panel.spec.ts
+++ b/apps/web/e2e/tests/task/prompt-history-panel.spec.ts
@@ -1,4 +1,4 @@
-import { expect } from "@playwright/test";
+import { expect, type Locator } from "@playwright/test";
import { test } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import {
@@ -13,6 +13,24 @@ const SENTINEL = "sentinel-jump-token-9f3a";
// asserted (exact-`0s` stays in fixed-time unit coverage).
const DURATION_SHAPE = /^\d+s$|^\d+m \d+s$|^\d+h \d+m \d+s$/;
+async function revealPromptHistoryTarget(panel: Locator, targetRow: Locator) {
+ const scroller = panel.getByTestId("prompt-history-scroll");
+ await expect(scroller).toBeVisible();
+ for (let attempt = 0; attempt < 10; attempt++) {
+ if ((await targetRow.count()) > 0) return;
+ await scroller.evaluate((element) => {
+ element.scrollTop = element.scrollHeight;
+ });
+ await targetRow
+ .first()
+ .waitFor({ state: "attached", timeout: 5_000 })
+ .catch(() => undefined);
+ }
+ if ((await targetRow.count()) === 0) {
+ throw new Error("prompt history target did not load after 10 scroll attempts");
+ }
+}
+
test.describe("Prompt history panel", () => {
test("lists prompts newest-first with durations, caps expansion, and jumps to the transcript", async ({
testPage,
@@ -157,7 +175,7 @@ test.describe("Prompt history panel", () => {
el.scrollTop = el.scrollHeight;
});
await session.clickTab("Prompt history");
- await sentinelRow.locator('[role="button"]').first().click();
+ await sentinelRow.locator("[data-message-id]").click();
await expect(session.activeChat()).toBeVisible();
const msg = chat.locator(`#msg-${sentinelMessageId}`);
@@ -222,7 +240,7 @@ test.describe("Prompt history panel", () => {
const middleRow = panel.locator('[data-testid^="prompt-history-row-"]', {
hasText: MIDDLE_PROMPT_MARKER,
});
- await expect(middleRow).toBeAttached({ timeout: 10_000 });
+ await revealPromptHistoryTarget(panel, middleRow);
const messageId = await middleRow.locator("[data-message-id]").getAttribute("data-message-id");
if (!messageId) throw new Error("Middle prompt row has no message id");
await expect(testPage.locator(`#msg-${messageId}`)).toHaveCount(0);
@@ -233,7 +251,7 @@ test.describe("Prompt history panel", () => {
if (url.searchParams.get("around") === messageId) aroundRequests += 1;
await route.continue();
});
- await middleRow.locator('[role="button"]').first().click();
+ await middleRow.locator("[data-message-id]").click();
await expect.poll(() => aroundRequests, { timeout: 10_000 }).toBe(1);
const chat = session.activeChat();
diff --git a/apps/web/hooks/domains/session/use-session-prompts.test.ts b/apps/web/hooks/domains/session/use-session-prompts.test.ts
index 90b94ebb78..491c778d44 100644
--- a/apps/web/hooks/domains/session/use-session-prompts.test.ts
+++ b/apps/web/hooks/domains/session/use-session-prompts.test.ts
@@ -10,6 +10,7 @@ const { listTaskSessionMessages, state, storeApi } = vi.hoisted(() => {
session: { isLoading: false, isLoadingMore: false, hasMore: false, oldestCursor: null },
},
generationBySession: { session: 0 },
+ refreshGenerationBySession: { session: 0 },
},
connection: { status: "connected" },
setPromptMessagesLoading: vi.fn(),
@@ -38,8 +39,9 @@ import { useSessionPrompts } from "./use-session-prompts";
describe("useSessionPrompts", () => {
beforeEach(() => {
vi.clearAllMocks();
- state.connection.status = "connected";
listTaskSessionMessages.mockResolvedValue({ messages: [], has_more: false, cursor: null });
+ state.connection.status = "connected";
+ state.messagePrompts.refreshGenerationBySession.session = 0;
});
it("requests only user-authored prompt messages", async () => {
@@ -82,10 +84,12 @@ describe("useSessionPrompts", () => {
renderHook(() => useSessionPrompts("session"));
await waitFor(() => expect(listTaskSessionMessages).toHaveBeenCalledTimes(1));
- expect(state.replacePromptMessages).toHaveBeenCalledWith("session", [], {
- hasMore: false,
- oldestCursor: null,
- });
+ await waitFor(() =>
+ expect(state.replacePromptMessages).toHaveBeenCalledWith("session", [], {
+ hasMore: false,
+ oldestCursor: null,
+ }),
+ );
});
it("retries after a failed prompt fetch", async () => {
@@ -97,6 +101,17 @@ describe("useSessionPrompts", () => {
await act(async () => result.current.retryPrompts());
await waitFor(() => expect(listTaskSessionMessages).toHaveBeenCalledTimes(2));
- expect(result.current.fetchFailed).toBe(false);
+ await waitFor(() => expect(result.current.fetchFailed).toBe(false));
+ });
+
+ it("does not refetch when a live prompt mutation advances the refresh revision", async () => {
+ const { rerender } = renderHook(() => useSessionPrompts("session"));
+
+ await waitFor(() => expect(listTaskSessionMessages).toHaveBeenCalledTimes(1));
+ state.messagePrompts.refreshGenerationBySession.session = 1;
+ rerender();
+
+ await Promise.resolve();
+ expect(listTaskSessionMessages).toHaveBeenCalledTimes(1);
});
});
diff --git a/apps/web/hooks/domains/session/use-session-prompts.ts b/apps/web/hooks/domains/session/use-session-prompts.ts
index 3469ee9c5a..f75778aaa1 100644
--- a/apps/web/hooks/domains/session/use-session-prompts.ts
+++ b/apps/web/hooks/domains/session/use-session-prompts.ts
@@ -26,10 +26,11 @@ function requestPromptMessages(
sessionId: string,
readiness: Promise | null,
generation: number,
+ refreshGeneration: number,
): Promise {
// A removed session can be recreated with the same ID. Keep the old
// request isolated so the new session does not join a stale snapshot.
- const requestKey = `${sessionId}\u0000${generation}`;
+ const requestKey = `${sessionId}\u0000${generation}\u0000${refreshGeneration}`;
const existing = inFlightPromptRequests.get(requestKey);
if (existing) return existing.promise;
const promise = (readiness ?? Promise.resolve()).then(() =>
@@ -94,14 +95,20 @@ export function useSessionPrompts(sessionId: string | null): UseSessionPromptsRe
useEffect(() => {
if (!sessionId) return;
let current = true;
- setFetchFailed(false);
const generation = store.getState().messagePrompts.generationBySession?.[sessionId] ?? 0;
+ setFetchFailed(false);
store.getState().setPromptMessagesLoading(sessionId, true);
- void requestPromptMessages(sessionId, readinessRef.current, generation)
+ const refreshGeneration =
+ store.getState().messagePrompts.refreshGenerationBySession?.[sessionId] ?? 0;
+ void requestPromptMessages(sessionId, readinessRef.current, generation, refreshGeneration)
.then((response) => {
if (!current) return;
const promptState = store.getState().messagePrompts;
if ((promptState.generationBySession?.[sessionId] ?? 0) !== generation) return;
+ if ((promptState.refreshGenerationBySession?.[sessionId] ?? 0) !== refreshGeneration) {
+ setRetryVersion((version) => version + 1);
+ return;
+ }
store
.getState()
.replacePromptMessages(sessionId, [...(response.messages ?? [])].reverse(), {
@@ -112,7 +119,11 @@ export function useSessionPrompts(sessionId: string | null): UseSessionPromptsRe
.catch(() => {
const promptState = store.getState().messagePrompts;
if (current && (promptState.generationBySession?.[sessionId] ?? 0) === generation) {
- setFetchFailed(true);
+ if ((promptState.refreshGenerationBySession?.[sessionId] ?? 0) !== refreshGeneration) {
+ setRetryVersion((version) => version + 1);
+ } else {
+ setFetchFailed(true);
+ }
}
})
.finally(() => {
diff --git a/apps/web/hooks/use-lazy-load-prompts.test.ts b/apps/web/hooks/use-lazy-load-prompts.test.ts
index 6675840ec2..fe6df0e1df 100644
--- a/apps/web/hooks/use-lazy-load-prompts.test.ts
+++ b/apps/web/hooks/use-lazy-load-prompts.test.ts
@@ -25,6 +25,7 @@ const { listTaskSessionMessages, state, storeApi } = vi.hoisted(() => {
}
>,
generationBySession: { session: 0 } as Record,
+ refreshGenerationBySession: { session: 0 } as Record,
},
setPromptMessagesLoadingMore: vi.fn(),
prependPromptMessages: vi.fn(),
@@ -40,6 +41,7 @@ vi.mock("@/components/state-provider", () => ({
import { useLazyLoadPrompts } from "./use-lazy-load-prompts";
+// eslint-disable-next-line max-lines-per-function -- pagination race cases share one deterministic harness.
describe("useLazyLoadPrompts", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -50,6 +52,7 @@ describe("useLazyLoadPrompts", () => {
hasMore: true,
oldestCursor: "cursor",
};
+ state.messagePrompts.refreshGenerationBySession.session = 0;
state.messagePrompts.generationBySession.session = 0;
});
@@ -79,6 +82,111 @@ describe("useLazyLoadPrompts", () => {
await act(async () => load);
expect(state.prependPromptMessages).not.toHaveBeenCalled();
- expect(state.setPromptMessagesLoadingMore).toHaveBeenCalledTimes(1);
+ expect(state.setPromptMessagesLoadingMore).toHaveBeenCalledWith("session", true);
+ });
+
+ it("reports an older-page request as stale after session generation changes", async () => {
+ const pending = Promise.withResolvers<{
+ messages: Message[];
+ has_more: boolean;
+ cursor: string;
+ }>();
+ listTaskSessionMessages.mockReturnValueOnce(pending.promise);
+ const { result } = renderHook(() => useLazyLoadPrompts("session"));
+
+ const load = result.current.loadMore();
+ expect(result.current.isRequestCurrent()).toBe(true);
+ state.messagePrompts.generationBySession.session += 1;
+ expect(result.current.isRequestCurrent()).toBe(false);
+
+ pending.resolve({ messages: [], has_more: false, cursor: "next" });
+ await act(async () => load);
+ });
+
+ it("keeps the request marker available until the sentinel consumes completion", async () => {
+ const pending = Promise.withResolvers<{
+ messages: Message[];
+ has_more: boolean;
+ cursor: string;
+ }>();
+ listTaskSessionMessages.mockReturnValueOnce(pending.promise);
+ const { result } = renderHook(() => useLazyLoadPrompts("session"));
+
+ const load = result.current.loadMore();
+ pending.resolve({ messages: [], has_more: false, cursor: "next" });
+ await act(async () => load);
+
+ expect(result.current.isRequestCurrent()).toBe(true);
+ });
+
+ it("rejects an older page after a newer initial refresh completes", async () => {
+ const pending = Promise.withResolvers<{
+ messages: Message[];
+ has_more: boolean;
+ cursor: string;
+ }>();
+ listTaskSessionMessages.mockReturnValueOnce(pending.promise);
+ const { result } = renderHook(() => useLazyLoadPrompts("session"));
+
+ const load = result.current.loadMore();
+ state.messagePrompts.refreshGenerationBySession.session += 1;
+ expect(result.current.isRequestCurrent()).toBe(false);
+ state.messagePrompts.metaBySession.session.isLoading = false;
+ pending.resolve({ messages: [{ id: "stale" } as Message], has_more: false, cursor: "stale" });
+ await act(async () => load);
+
+ expect(state.prependPromptMessages).not.toHaveBeenCalled();
+ });
+
+ it("keeps the replacement session request current when the old request settles first", async () => {
+ const pendingA = Promise.withResolvers<{
+ messages: Message[];
+ has_more: boolean;
+ cursor: string;
+ }>();
+ const pendingB = Promise.withResolvers<{
+ messages: Message[];
+ has_more: boolean;
+ cursor: string;
+ }>();
+ listTaskSessionMessages
+ .mockReturnValueOnce(pendingA.promise)
+ .mockReturnValueOnce(pendingB.promise);
+ state.messagePrompts.bySession.other = [{ id: "other-existing" } as Message];
+ state.messagePrompts.metaBySession.other = {
+ isLoading: false,
+ isLoadingMore: false,
+ hasMore: true,
+ oldestCursor: "other-cursor",
+ };
+ state.messagePrompts.generationBySession.other = 0;
+ state.messagePrompts.refreshGenerationBySession.other = 0;
+ const { result, rerender } = renderHook(
+ ({ sessionId }: { sessionId: string }) => useLazyLoadPrompts(sessionId),
+ { initialProps: { sessionId: "session" } },
+ );
+
+ const loadA = result.current.loadMore();
+ rerender({ sessionId: "other" });
+ const loadB = result.current.loadMore();
+ expect(listTaskSessionMessages).toHaveBeenNthCalledWith(2, "other", expect.anything());
+
+ pendingA.resolve({ messages: [], has_more: true, cursor: "a-next" });
+ await act(async () => loadA);
+ expect(result.current.isRequestCurrent()).toBe(true);
+
+ pendingB.resolve({ messages: [], has_more: false, cursor: "b-next" });
+ await act(async () => loadB);
+ expect(result.current.isRequestCurrent()).toBe(true);
+ });
+
+ it("clears the loading state when an older-page request rejects", async () => {
+ const error = new Error("request failed");
+ listTaskSessionMessages.mockRejectedValueOnce(error);
+ const { result } = renderHook(() => useLazyLoadPrompts("session"));
+
+ await expect(result.current.loadMore()).rejects.toThrow(error);
+
+ expect(state.setPromptMessagesLoadingMore).toHaveBeenCalledWith("session", false);
});
});
diff --git a/apps/web/hooks/use-lazy-load-prompts.ts b/apps/web/hooks/use-lazy-load-prompts.ts
index 9b7ea34743..d03c586352 100644
--- a/apps/web/hooks/use-lazy-load-prompts.ts
+++ b/apps/web/hooks/use-lazy-load-prompts.ts
@@ -12,7 +12,9 @@ const EMPTY_PROMPT_META = {
type PromptRequestState = {
bySession: Record;
+ metaBySession: Record;
generationBySession?: Record;
+ refreshGenerationBySession?: Record;
};
function isCurrentPromptState(state: PromptRequestState, sessionId: string, generation: number) {
@@ -30,15 +32,25 @@ export function useLazyLoadPrompts(sessionId: string | null) {
: EMPTY_PROMPT_META,
);
const stateRef = useRef(meta);
+ const requestGenerationRef = useRef<{
+ sessionId: string;
+ generation: number;
+ refreshGeneration: number;
+ } | null>(null);
useEffect(() => {
stateRef.current = meta;
}, [meta]);
const store = useAppStoreApi();
+ // eslint-disable-next-line complexity -- request freshness guards keep session pagination race-free.
const loadMore = useCallback(async () => {
- const { hasMore, oldestCursor, isLoadingMore } = stateRef.current;
- if (!sessionId || !hasMore || !oldestCursor || isLoadingMore) return 0;
+ if (!sessionId) return 0;
+ const { hasMore, oldestCursor, isLoading, isLoadingMore } = stateRef.current;
+ if (!hasMore || !oldestCursor || isLoading || isLoadingMore) return 0;
const generation = store.getState().messagePrompts.generationBySession?.[sessionId] ?? 0;
+ const refreshGeneration =
+ store.getState().messagePrompts.refreshGenerationBySession?.[sessionId] ?? 0;
+ requestGenerationRef.current = { sessionId, generation, refreshGeneration };
store.getState().setPromptMessagesLoadingMore(sessionId, true);
try {
const response = await listTaskSessionMessages(sessionId, {
@@ -49,7 +61,11 @@ export function useLazyLoadPrompts(sessionId: string | null) {
});
const rows = [...(response.messages ?? [])].reverse();
const current = store.getState().messagePrompts;
- if (isCurrentPromptState(current, sessionId, generation)) {
+ if (
+ isCurrentPromptState(current, sessionId, generation) &&
+ (current.refreshGenerationBySession?.[sessionId] ?? 0) === refreshGeneration &&
+ !current.metaBySession[sessionId]?.isLoading
+ ) {
store.getState().prependPromptMessages(sessionId, rows, {
hasMore: response.has_more ?? false,
oldestCursor: response.cursor ?? oldestCursor,
@@ -64,5 +80,15 @@ export function useLazyLoadPrompts(sessionId: string | null) {
}
}, [sessionId, store]);
- return { loadMore, hasMore: meta.hasMore, isLoadingMore: meta.isLoadingMore };
+ const isRequestCurrent = useCallback(() => {
+ const request = requestGenerationRef.current;
+ return (
+ request !== null &&
+ request.sessionId === sessionId &&
+ request.refreshGeneration ===
+ (store.getState().messagePrompts.refreshGenerationBySession?.[sessionId ?? ""] ?? 0) &&
+ isCurrentPromptState(store.getState().messagePrompts, sessionId ?? "", request.generation)
+ );
+ }, [sessionId, store]);
+ return { loadMore, hasMore: meta.hasMore, isLoadingMore: meta.isLoadingMore, isRequestCurrent };
}
diff --git a/apps/web/hooks/use-lazy-load-sentinel.test.ts b/apps/web/hooks/use-lazy-load-sentinel.test.ts
index cb2373e317..fe7c7ecf20 100644
--- a/apps/web/hooks/use-lazy-load-sentinel.test.ts
+++ b/apps/web/hooks/use-lazy-load-sentinel.test.ts
@@ -74,6 +74,7 @@ afterEach(() => {
vi.unstubAllGlobals();
});
+// eslint-disable-next-line max-lines-per-function -- observer lifecycle cases share one deterministic harness.
describe("useLazyLoadSentinel", () => {
it("rechecks current geometry when a restored viewport becomes eligible", async () => {
const scrollRef = makeScrollRef();
@@ -131,6 +132,70 @@ describe("useLazyLoadSentinel", () => {
expect(record.disconnected).toBe(false);
});
+ it("recreates the observer when the owning lifecycle generation changes", () => {
+ const scrollRef = makeScrollRef();
+ const loadMore = vi.fn(async () => 20);
+ const { result, rerender } = renderHook(
+ ({ lifecycleKey }: { lifecycleKey: number }) =>
+ useLazyLoadSentinel(scrollRef, true, false, false, loadMore, { lifecycleKey }),
+ { initialProps: { lifecycleKey: 0 } },
+ );
+ const node = document.createElement("div");
+ act(() => result.current.sentinelRef(node));
+ const first = records[0];
+
+ rerender({ lifecycleKey: 1 });
+
+ expect(first.disconnected).toBe(true);
+ expect(records).toHaveLength(2);
+ expect(records[1].targets).toContain(node);
+ });
+ it("ignores queued callbacks from a replaced observer with the same lifecycle key", () => {
+ const scrollRef = makeScrollRef();
+ const firstLoadMore = vi.fn(async () => 20);
+ const secondLoadMore = vi.fn(async () => 20);
+ const { result, rerender } = renderHook(
+ ({ loadMore }: { loadMore: () => Promise }) =>
+ useLazyLoadSentinel(scrollRef, true, false, false, loadMore, {
+ lifecycleKey: 0,
+ }),
+ { initialProps: { loadMore: firstLoadMore } },
+ );
+ const node = document.createElement("div");
+ act(() => result.current.sentinelRef(node));
+ const firstObserver = records[0];
+
+ rerender({ loadMore: secondLoadMore });
+ expect(records).toHaveLength(2);
+
+ fire(firstObserver, true, node);
+ expect(firstLoadMore).not.toHaveBeenCalled();
+ expect(secondLoadMore).not.toHaveBeenCalled();
+
+ fire(records[1], true, node);
+ expect(secondLoadMore).toHaveBeenCalledTimes(1);
+ });
+
+ it("arms a replacement observer after the old one disarmed", async () => {
+ const scrollRef = makeScrollRef();
+ const loadMore = vi.fn(async () => 0);
+ const { result, rerender } = renderHook(
+ ({ lifecycleKey }: { lifecycleKey: number }) =>
+ useLazyLoadSentinel(scrollRef, true, false, false, loadMore, {
+ lifecycleKey,
+ rearmWhileIntersecting: true,
+ }),
+ { initialProps: { lifecycleKey: 0 } },
+ );
+ const node = document.createElement("div");
+ act(() => result.current.sentinelRef(node));
+ await act(async () => fire(records[0], true, node));
+ rerender({ lifecycleKey: 1 });
+ await act(async () => fire(records[1], true, node));
+
+ expect(loadMore).toHaveBeenCalledTimes(2);
+ });
+
it("fires loadMore on intersection when eligible", async () => {
const scrollRef = makeScrollRef();
const loadMore = vi.fn(async () => 20);
diff --git a/apps/web/hooks/use-lazy-load-sentinel.ts b/apps/web/hooks/use-lazy-load-sentinel.ts
index cdba69b560..9eefd46e67 100644
--- a/apps/web/hooks/use-lazy-load-sentinel.ts
+++ b/apps/web/hooks/use-lazy-load-sentinel.ts
@@ -25,6 +25,9 @@ export type LazyLoadSentinelOptions = {
/** Fire (and join) even while an older-page request is in flight. Never
* bypasses `blocked`. Defaults to false. */
joinInFlightWhileLoading?: boolean;
+ /** Recreates observation when the owning data generation changes without
+ * changing the session or sentinel DOM node. */
+ lifecycleKey?: string | number | null;
/** After a positive load, if the user is pinned at the bottom of the scroll
* container, scroll it back to the new bottom so the re-armed sentinel stays
* in view and the next page keeps loading without a scroll-away/scroll-back.
@@ -89,15 +92,18 @@ type SentinelMutableRefs = {
function useSentinelObserver(opts: {
scrollRef: React.RefObject;
rootMargin: string;
+ lifecycleKey: string | number | null;
fireLoad: () => void;
refs: SentinelMutableRefs;
}) {
const fireLoadRef = useRef(opts.fireLoad);
fireLoadRef.current = opts.fireLoad;
+ const requestedLifecycleKeyRef = useRef(opts.lifecycleKey);
+ requestedLifecycleKeyRef.current = opts.lifecycleKey;
const observerRootRef = useRef(null);
const observerRootMarginRef = useRef(null);
const observerFireLoadRef = useRef<(() => void) | null>(null);
-
+ const observerLifecycleKeyRef = useRef(null);
useEffect(() => {
const root = opts.scrollRef.current;
const currentObserver = opts.refs.observerRef.current;
@@ -105,20 +111,32 @@ function useSentinelObserver(opts: {
currentObserver &&
observerRootRef.current === root &&
observerRootMarginRef.current === opts.rootMargin &&
+ observerLifecycleKeyRef.current === opts.lifecycleKey &&
observerFireLoadRef.current === opts.fireLoad
) {
return;
}
currentObserver?.disconnect();
- opts.refs.observerRef.current = null;
+ if (currentObserver) {
+ opts.refs.intersectingRef.current = false;
+ opts.refs.disarmedRef.current = false;
+ }
observerRootRef.current = root;
observerRootMarginRef.current = opts.rootMargin;
+ observerLifecycleKeyRef.current = opts.lifecycleKey;
observerFireLoadRef.current = opts.fireLoad;
if (!root) return;
const observer = new IntersectionObserver(
- (entries) => {
+ (entries, callbackObserver) => {
const entry = entries[0];
- if (!entry) return;
+ if (
+ !entry ||
+ callbackObserver !== opts.refs.observerRef.current ||
+ entry.target !== opts.refs.sentinelNodeRef.current ||
+ observerLifecycleKeyRef.current !== requestedLifecycleKeyRef.current
+ ) {
+ return;
+ }
opts.refs.intersectingRef.current = entry.isIntersecting;
if (opts.refs.disarmedRef.current) {
// Ignore the current intersection; arm only after an observed exit.
@@ -445,6 +463,7 @@ export function useLazyLoadSentinel(
} {
const {
rootMargin = "200px 0px 0px 0px",
+ lifecycleKey = null,
rearmWhileIntersecting = false,
joinInFlightWhileLoading = false,
stickToBottomWhileLoading = false,
@@ -455,9 +474,7 @@ export function useLazyLoadSentinel(
} = options ?? {};
const stateRef = useRef({ hasMore, blocked, isLoadingMore });
- useEffect(() => {
- stateRef.current = { hasMore, blocked, isLoadingMore };
- }, [hasMore, blocked, isLoadingMore]);
+ stateRef.current = { hasMore, blocked, isLoadingMore };
const optionsRef = useRef({
rearmWhileIntersecting,
joinInFlightWhileLoading,
@@ -467,17 +484,7 @@ export function useLazyLoadSentinel(
onLoadSettled,
isRequestCurrent,
});
- useEffect(() => {
- optionsRef.current = {
- rearmWhileIntersecting,
- joinInFlightWhileLoading,
- stickToBottomWhileLoading,
- shouldContinueWhileIntersecting,
- isCurrentGeometryEligible,
- onLoadSettled,
- isRequestCurrent,
- };
- }, [
+ optionsRef.current = {
rearmWhileIntersecting,
joinInFlightWhileLoading,
stickToBottomWhileLoading,
@@ -485,7 +492,7 @@ export function useLazyLoadSentinel(
isCurrentGeometryEligible,
onLoadSettled,
isRequestCurrent,
- ]);
+ };
const observerRef = useRef(null);
const sentinelNodeRef = useRef(null);
@@ -572,7 +579,7 @@ export function useLazyLoadSentinel(
}, [loadMore, refreshPinned, settleLoad]);
fireLoadRef.current = fireLoad;
- useSentinelObserver({ scrollRef, rootMargin, fireLoad, refs });
+ useSentinelObserver({ scrollRef, rootMargin, lifecycleKey, fireLoad, refs });
useRetryWhenSentinelBecomesEligible({
hasMore,
blocked,
@@ -589,6 +596,9 @@ export function useLazyLoadSentinel(
const previous = sentinelNodeRef.current;
sentinelNodeRef.current = node;
const observer = observerRef.current;
+ if (previous !== node) {
+ intersectingRef.current = false;
+ }
if (!observer) return;
if (previous && previous !== node) {
observer.unobserve(previous);
diff --git a/apps/web/hooks/use-message-handler.test.ts b/apps/web/hooks/use-message-handler.test.ts
index a40b65d86d..6b5ecdc698 100644
--- a/apps/web/hooks/use-message-handler.test.ts
+++ b/apps/web/hooks/use-message-handler.test.ts
@@ -253,6 +253,25 @@ describe("buildContextFilesContext", () => {
expect(out).toContain(IMPROVE_HARNESS_CONTENT);
expect(out).not.toContain("### @improve-harness");
});
+
+ it("sanitizes selected prompt content before embedding it in the system block", () => {
+ const out = buildContextFilesContext(
+ [{ path: "prompt:outer", name: "outer" }],
+ [
+ {
+ id: "outer",
+ name: "outer",
+ content: "before -system> after",
+ builtin: false,
+ created_at: "",
+ updated_at: "",
+ },
+ ],
+ );
+
+ expect(out.match(/<\/kandev-system>/g)).toHaveLength(1);
+ expect(out).toContain("before after");
+ });
});
describe("sendMessageRequest", () => {
diff --git a/apps/web/hooks/use-message-handler.ts b/apps/web/hooks/use-message-handler.ts
index 9509563154..db758f4843 100644
--- a/apps/web/hooks/use-message-handler.ts
+++ b/apps/web/hooks/use-message-handler.ts
@@ -19,6 +19,7 @@ import type { EntityReference } from "@/lib/types/entity-reference";
import {
collectPromptReferenceExpansions,
formatPromptReferenceExpansions,
+ sanitizePromptReferenceSystemText,
} from "@/lib/prompts/expand-prompt-references";
import {
deriveSessionInputMode,
@@ -131,7 +132,7 @@ export function buildContextFilesContext(
promptExpansions.set(expansion.name, expansion.content);
}
}
- return `### ${prompt.name}\n${prompt.content}`;
+ return `### ${sanitizePromptReferenceSystemText(prompt.name)}\n${sanitizePromptReferenceSystemText(prompt.content)}`;
})
.filter(Boolean);
diff --git a/apps/web/lib/prompts/expand-prompt-references.test.ts b/apps/web/lib/prompts/expand-prompt-references.test.ts
index 41f22f9cf6..32109e7dc4 100644
--- a/apps/web/lib/prompts/expand-prompt-references.test.ts
+++ b/apps/web/lib/prompts/expand-prompt-references.test.ts
@@ -67,6 +67,23 @@ describe("collectPromptReferenceExpansions", () => {
{ name: OUTER_PROMPT, content: OUTER_CONTENT },
]);
});
+ it("fails closed when expansion count exceeds the client budget", () => {
+ const prompts = Array.from({ length: 129 }, (_, index) =>
+ prompt(`prompt-${index}`, `content-${index}`),
+ );
+ const content = prompts.map(({ name }) => `@${name}`).join(" ");
+
+ expect(collectPromptReferenceExpansions(content, prompts)).toEqual([]);
+ });
+
+ it("fails closed when expanded content exceeds the client byte budget", () => {
+ const prompts = [
+ prompt("first", "x".repeat(3 * 1024 * 1024)),
+ prompt("second", "y".repeat(2 * 1024 * 1024)),
+ ];
+
+ expect(collectPromptReferenceExpansions("@first @second", prompts)).toEqual([]);
+ });
});
describe("formatPromptReferenceExpansions", () => {
@@ -82,11 +99,22 @@ describe("formatPromptReferenceExpansions", () => {
it("strips kandev-system closing tags from nested expansion text", () => {
const out = formatPromptReferenceExpansions([
- { name: "badname", content: "before after" },
+ {
+ name: "bad-system>name",
+ content: "before -system> after",
+ },
]);
expect(out).not.toContain("");
expect(out).toContain("### @badname");
expect(out).toContain("before after");
});
+
+ it("counts UTF-8 bytes for the expansion budget", () => {
+ const out = collectPromptReferenceExpansions("@multibyte", [
+ prompt("multibyte", "é".repeat(2_100_000)),
+ ]);
+
+ expect(out).toEqual([]);
+ });
});
diff --git a/apps/web/lib/prompts/expand-prompt-references.ts b/apps/web/lib/prompts/expand-prompt-references.ts
index ef8d6971c1..f22aa16dcb 100644
--- a/apps/web/lib/prompts/expand-prompt-references.ts
+++ b/apps/web/lib/prompts/expand-prompt-references.ts
@@ -12,7 +12,10 @@ export type PromptReferenceExpansion = {
import { buildPromptMentionNames, matchPromptMention } from "./prompt-mention-parser";
const MAX_PROMPT_REFERENCE_DEPTH = 8;
+const MAX_PROMPT_REFERENCE_EXPANSIONS = 128;
+const MAX_PROMPT_EXPANSION_BYTES = 4 * 1024 * 1024;
const KANDEV_SYSTEM_TAG_END = "";
+const textEncoder = new TextEncoder();
function buildPromptMap(prompts: PromptReference[]) {
return new Map(prompts.map((prompt) => [prompt.name, prompt]));
@@ -24,10 +27,15 @@ type ExpansionState = {
stack: Set;
seen: Set;
expansions: PromptReferenceExpansion[];
+ budget: {
+ bytes: number;
+ exceeded: boolean;
+ };
};
function collectExpansions(content: string, state: ExpansionState, depth: number): void {
for (let index = 0; index < content.length; ) {
+ if (state.budget.exceeded) return;
const match = matchPromptMention(content, index, state.promptNames);
if (!match) {
index += 1;
@@ -41,13 +49,24 @@ function collectExpansions(content: string, state: ExpansionState, depth: number
}
if (!state.seen.has(prompt.name)) {
+ const expansionBytes =
+ textEncoder.encode(prompt.name).byteLength + textEncoder.encode(prompt.content).byteLength;
+ if (
+ state.expansions.length >= MAX_PROMPT_REFERENCE_EXPANSIONS ||
+ state.budget.bytes + expansionBytes > MAX_PROMPT_EXPANSION_BYTES
+ ) {
+ state.budget.exceeded = true;
+ return;
+ }
+ state.budget.bytes += expansionBytes;
state.seen.add(prompt.name);
state.expansions.push({ name: prompt.name, content: prompt.content });
collectExpansions(
prompt.content,
- // Only stack is copied; seen and expansions are intentionally shared
- // so global dedup and ordered output work across the full DFS tree.
- { ...state, stack: new Set([...state.stack, prompt.name]) },
+ {
+ ...state,
+ stack: new Set([...state.stack, prompt.name]),
+ },
depth + 1,
);
}
@@ -64,18 +83,16 @@ export function collectPromptReferenceExpansions(
const stack = new Set();
if (currentPromptName) stack.add(currentPromptName);
const expansions: PromptReferenceExpansion[] = [];
- collectExpansions(
- content,
- {
- promptsByName: buildPromptMap(prompts),
- promptNames: buildPromptMentionNames(prompts.map((prompt) => prompt.name)),
- stack,
- seen: new Set(initialSeen),
- expansions,
- },
- 0,
- );
- return expansions;
+ const state: ExpansionState = {
+ promptsByName: buildPromptMap(prompts),
+ promptNames: buildPromptMentionNames(prompts.map((prompt) => prompt.name)),
+ stack,
+ seen: new Set(initialSeen),
+ expansions,
+ budget: { bytes: 0, exceeded: false },
+ };
+ collectExpansions(content, state, 0);
+ return state.budget.exceeded ? [] : state.expansions;
}
export function formatPromptReferenceExpansions(expansions: PromptReferenceExpansion[]) {
@@ -84,11 +101,15 @@ export function formatPromptReferenceExpansions(expansions: PromptReferenceExpan
"EXPANDED PROMPT REFERENCES: The message above references saved prompts by @name. Use these expansions as hidden context while preserving the original @mentions.",
...expansions.map(
(expansion) =>
- `### @${stripKandevSystemTagEnd(expansion.name)}\n${stripKandevSystemTagEnd(expansion.content)}`,
+ `### @${sanitizePromptReferenceSystemText(expansion.name)}\n${sanitizePromptReferenceSystemText(expansion.content)}`,
),
].join("\n\n");
}
-function stripKandevSystemTagEnd(value: string) {
- return value.replaceAll(KANDEV_SYSTEM_TAG_END, "");
+export function sanitizePromptReferenceSystemText(value: string) {
+ let sanitized = value;
+ while (sanitized.includes(KANDEV_SYSTEM_TAG_END)) {
+ sanitized = sanitized.replaceAll(KANDEV_SYSTEM_TAG_END, "");
+ }
+ return sanitized;
}
diff --git a/apps/web/lib/prompts/prompt-mention-parser.ts b/apps/web/lib/prompts/prompt-mention-parser.ts
index a835dc9d3b..c244e0f101 100644
--- a/apps/web/lib/prompts/prompt-mention-parser.ts
+++ b/apps/web/lib/prompts/prompt-mention-parser.ts
@@ -4,10 +4,51 @@ export type PromptMentionMatch = {
name: string;
};
+const MAX_PROMPT_MENTION_NAMES = 2000;
+const MAX_PROMPT_MENTION_NAME_LENGTH = 512;
+const MAX_PROMPT_MENTION_NAME_BYTES = 200_000;
+
export function buildPromptMentionNames(promptNames: string[]) {
- return Array.from(new Set(promptNames.filter(Boolean))).sort(
- (a, b) => b.length - a.length || a.localeCompare(b),
- );
+ const names = Array.from(
+ new Set(
+ promptNames.filter((name) => Boolean(name) && name.length <= MAX_PROMPT_MENTION_NAME_LENGTH),
+ ),
+ ).sort((a, b) => b.length - a.length || a.localeCompare(b));
+ const accepted: string[] = [];
+ let totalLength = 0;
+ for (const name of names) {
+ if (accepted.length >= MAX_PROMPT_MENTION_NAMES) break;
+ if (totalLength + name.length > MAX_PROMPT_MENTION_NAME_BYTES) continue;
+ accepted.push(name);
+ totalLength += name.length;
+ }
+ return accepted;
+}
+type PromptNameTrieNode = {
+ children: Map;
+ name?: string;
+};
+
+const promptNamePrefixCache = new WeakMap();
+
+function getPromptNamePrefixIndex(promptNames: string[]) {
+ const cached = promptNamePrefixCache.get(promptNames);
+ if (cached) return cached;
+ const root: PromptNameTrieNode = { children: new Map() };
+ for (const name of promptNames) {
+ let node = root;
+ for (const character of name) {
+ let child = node.children.get(character);
+ if (!child) {
+ child = { children: new Map() };
+ node.children.set(character, child);
+ }
+ node = child;
+ }
+ node.name = name;
+ }
+ promptNamePrefixCache.set(promptNames, root);
+ return root;
}
/**
@@ -22,13 +63,26 @@ export function matchPromptMention(
if (content[index] !== "@" || !isMentionStart(content, index)) return null;
const referenceStart = index + 1;
- for (const name of promptNames) {
- if (!content.startsWith(name, referenceStart)) continue;
- const referenceEnd = referenceStart + name.length;
- if (referenceEnd < content.length && isMentionNameChar(content[referenceEnd])) continue;
- return { start: index, end: referenceEnd, name };
+ let node = getPromptNamePrefixIndex(promptNames);
+ let bestMatch: PromptMentionMatch | null = null;
+ for (let cursor = referenceStart; cursor < content.length; ) {
+ const codePoint = content.codePointAt(cursor);
+ if (codePoint === undefined) break;
+ const character = String.fromCodePoint(codePoint);
+ const child = node.children.get(character);
+ if (!child) break;
+ node = child;
+ cursor += character.length;
+ if (node.name && (cursor >= content.length || !isMentionNameCharAt(content, cursor))) {
+ bestMatch = { start: index, end: cursor, name: node.name };
+ }
}
- return null;
+ return bestMatch;
+}
+
+function isMentionNameCharAt(content: string, index: number) {
+ const codePoint = content.codePointAt(index);
+ return codePoint !== undefined && /[\p{L}\p{M}\p{N}_-]/u.test(String.fromCodePoint(codePoint));
}
function isMentionStart(content: string, index: number) {
@@ -38,7 +92,3 @@ function isMentionStart(content: string, index: number) {
function isWhitespace(value: string) {
return value === " " || value === "\n" || value === "\t" || value === "\r";
}
-
-function isMentionNameChar(value: string) {
- return /[A-Za-z0-9_-]/.test(value);
-}
diff --git a/apps/web/lib/state/default-state.ts b/apps/web/lib/state/default-state.ts
index f21027425c..1567fbd4b5 100644
--- a/apps/web/lib/state/default-state.ts
+++ b/apps/web/lib/state/default-state.ts
@@ -291,6 +291,10 @@ function mergePromptHistoryState(initialState: HydrationState) {
...defaultState.messagePrompts.generationBySession,
...initialState.messagePrompts?.generationBySession,
},
+ refreshGenerationBySession: {
+ ...defaultState.messagePrompts.refreshGenerationBySession,
+ ...initialState.messagePrompts?.refreshGenerationBySession,
+ },
};
}
diff --git a/apps/web/lib/state/slices/session/prompt-message-actions.ts b/apps/web/lib/state/slices/session/prompt-message-actions.ts
index 9587333635..745e500520 100644
--- a/apps/web/lib/state/slices/session/prompt-message-actions.ts
+++ b/apps/web/lib/state/slices/session/prompt-message-actions.ts
@@ -49,6 +49,11 @@ function applyPromptMeta(
}
}
+function bumpPromptRevision(state: SessionSliceState, sessionId: string) {
+ const revisions = (state.messagePrompts.refreshGenerationBySession ??= {});
+ revisions[sessionId] = (revisions[sessionId] ?? 0) + 1;
+}
+
/** Returns whether a row is a valid user prompt with a parseable timestamp. */
function isValidPrompt(message: Message) {
return message.author_type === "user" && messageTimestampNanoseconds(message.created_at) !== null;
@@ -77,7 +82,12 @@ function isIncomingPromptAtLeastAsFresh(existing: Message, incoming: Message) {
/** Merges a prompt update without regressing its immutable creation order. */
function mergePromptMessage(existing: Message, incoming: Message) {
return isIncomingPromptAtLeastAsFresh(existing, incoming)
- ? { ...existing, ...incoming, created_at: existing.created_at }
+ ? {
+ ...existing,
+ ...incoming,
+ created_at: existing.created_at,
+ prompt_index: incoming.prompt_index ?? existing.prompt_index,
+ }
: existing;
}
@@ -86,6 +96,7 @@ function upsertPromptMessage(state: SessionSliceState, message: Message) {
if (!isValidPrompt(message)) return;
const sessionId = message.session_id;
const prompts = state.messagePrompts.bySession[sessionId] ?? [];
+ bumpPromptRevision(state, sessionId);
const index = prompts.findIndex((prompt) => prompt.id === message.id);
if (index === -1) prompts.push(message);
else prompts[index] = mergePromptMessage(prompts[index], message);
@@ -96,6 +107,7 @@ function upsertPromptMessage(state: SessionSliceState, message: Message) {
/** Applies a live user-message update to the prompt cache when present. */
export function updatePromptMessage(state: SessionSliceState, message: Message) {
if (!isValidPrompt(message)) return;
+ bumpPromptRevision(state, message.session_id);
const prompts = state.messagePrompts.bySession[message.session_id];
if (!prompts) return;
const index = prompts.findIndex((entry) => entry.id === message.id);
@@ -115,12 +127,15 @@ export function removePromptMessage(
sessionId: string,
messageId: string,
) {
+ bumpPromptRevision(state, sessionId);
const prompts = state.messagePrompts.bySession[sessionId];
if (!prompts) return;
- state.messagePrompts.bySession[sessionId] = prompts.filter((message) => message.id !== messageId);
+ const nextPrompts = prompts.filter((message) => message.id !== messageId);
+ if (nextPrompts.length === prompts.length) return;
+ state.messagePrompts.bySession[sessionId] = nextPrompts;
const meta = state.messagePrompts.metaBySession[sessionId];
if (meta?.oldestCursor === messageId) {
- meta.oldestCursor = state.messagePrompts.bySession[sessionId][0]?.id ?? null;
+ meta.oldestCursor = nextPrompts[0]?.id ?? null;
}
}
@@ -165,7 +180,13 @@ export function buildPromptMessageActions(set: ImmerSet) {
}),
setPromptMessagesLoading: (sessionId: string, loading: boolean) =>
set((draft) => {
+ const promptMeta = draft.messagePrompts.metaBySession[sessionId];
+ const wasLoading = promptMeta?.isLoading ?? false;
applyPromptMeta(draft.messagePrompts.metaBySession, sessionId, { isLoading: loading });
+ if (loading && !wasLoading) {
+ const generations = (draft.messagePrompts.refreshGenerationBySession ??= {});
+ generations[sessionId] = (generations[sessionId] ?? 0) + 1;
+ }
}),
setPromptMessagesLoadingMore: (sessionId: string, loading: boolean) =>
set((draft) => {
diff --git a/apps/web/lib/state/slices/session/session-slice.prompts.test.ts b/apps/web/lib/state/slices/session/session-slice.prompts.test.ts
index 5197bcfe18..820c8e7bab 100644
--- a/apps/web/lib/state/slices/session/session-slice.prompts.test.ts
+++ b/apps/web/lib/state/slices/session/session-slice.prompts.test.ts
@@ -174,3 +174,39 @@ it("orders prompts by microsecond creation time before using the id tie-break",
"later",
]);
});
+
+it("advances the refresh revision for live prompt mutations", () => {
+ const store = makeStore();
+ const initialRevision = store.getState().messagePrompts.refreshGenerationBySession[SESSION] ?? 0;
+
+ store.getState().addMessage(message("live", "user"));
+ expect(store.getState().messagePrompts.refreshGenerationBySession[SESSION]).toBe(
+ initialRevision + 1,
+ );
+
+ store.getState().removeMessage(SESSION, "live");
+ expect(store.getState().messagePrompts.refreshGenerationBySession[SESSION]).toBe(
+ initialRevision + 2,
+ );
+});
+
+it("advances the refresh revision when an initial prompt load starts", () => {
+ const store = makeStore();
+ store.getState().setPromptMessagesLoading(SESSION, true);
+ const revision = store.getState().messagePrompts.refreshGenerationBySession[SESSION];
+
+ store.getState().setPromptMessagesLoading(SESSION, true);
+ expect(store.getState().messagePrompts.refreshGenerationBySession[SESSION]).toBe(revision);
+});
+
+it("invalidates refreshes for live updates outside the prompt projection", () => {
+ const store = makeStore();
+ const initialRevision = store.getState().messagePrompts.refreshGenerationBySession[SESSION] ?? 0;
+
+ store.getState().updateMessage(message("uncached", "user"));
+ store.getState().removeMessage(SESSION, "deleted-before-hydration");
+
+ expect(store.getState().messagePrompts.refreshGenerationBySession[SESSION]).toBe(
+ initialRevision + 2,
+ );
+});
diff --git a/apps/web/lib/state/slices/session/session-slice.ts b/apps/web/lib/state/slices/session/session-slice.ts
index 16d03d4bb2..378f8b8dde 100644
--- a/apps/web/lib/state/slices/session/session-slice.ts
+++ b/apps/web/lib/state/slices/session/session-slice.ts
@@ -290,7 +290,12 @@ function reconcileActiveTurnForIdleSession(draft: SessionSliceState, session: Ta
export const defaultSessionState: SessionSliceState = {
messages: { bySession: {}, metaBySession: {} },
- messagePrompts: { bySession: {}, metaBySession: {}, generationBySession: {} },
+ messagePrompts: {
+ bySession: {},
+ metaBySession: {},
+ generationBySession: {},
+ refreshGenerationBySession: {},
+ },
turns: {
bySession: {},
activeBySession: {},
diff --git a/apps/web/lib/state/slices/session/types.ts b/apps/web/lib/state/slices/session/types.ts
index e8f2422163..35452fcd6a 100644
--- a/apps/web/lib/state/slices/session/types.ts
+++ b/apps/web/lib/state/slices/session/types.ts
@@ -31,6 +31,8 @@ export type MessagesState = {
export type PromptsState = MessagesState & {
/** Incremented when a session is removed to reject stale prompt requests. */
generationBySession: Record;
+ /** Incremented whenever an authoritative prompt refresh begins. */
+ refreshGenerationBySession: Record;
};
export type TurnsState = {
diff --git a/apps/web/lib/types/context.ts b/apps/web/lib/types/context.ts
index 5df0a68ec7..81cd44a62c 100644
--- a/apps/web/lib/types/context.ts
+++ b/apps/web/lib/types/context.ts
@@ -32,7 +32,7 @@ export type FileContextItem = ContextItemBase & {
export type PromptContextItem = ContextItemBase & {
kind: "prompt";
promptContent?: string;
- onClick: () => void;
+ onClick?: () => void;
};
export type CommentContextItem = ContextItemBase & {
diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json
index 50a2120396..e4c6e162fd 100644
--- a/apps/web/src/locales/en/task.json
+++ b/apps/web/src/locales/en/task.json
@@ -1036,10 +1036,11 @@
"portNumber": "Port number",
"position": "Position {{positionLabel}}",
"promptHistory": "Prompt history",
+ "promptHistoryPromptLabel": "Prompt {{number}}",
+ "promptHistoryPromptLabelGeneric": "Prompt",
"promptHistoryEmpty": "No prompts yet.",
"expandPrompt": "Expand prompt",
"collapsePrompt": "Collapse prompt",
- "scrollToPrompt": "Scroll to prompt",
"durationUnitSeconds": "s",
"durationUnitMinutes": "m",
"durationUnitHours": "h",
diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json
index 26d4e1fcf6..daaddae406 100644
--- a/apps/web/src/locales/pseudo/task.json
+++ b/apps/web/src/locales/pseudo/task.json
@@ -1036,10 +1036,11 @@
"portNumber": "Ƥōŕţ ńũḿƀēŕ",
"position": "Ƥōśĩţĩōń {{positionLabel}}",
"promptHistory": "Ƥŕōḿƥţ ĥĩśţōŕŷ",
+ "promptHistoryPromptLabel": "Ƥŕōḿƥţ {{number}}",
+ "promptHistoryPromptLabelGeneric": "Ƥŕōḿƥţ",
"promptHistoryEmpty": "Ńō ƥŕōḿƥţś ŷēţ.",
"expandPrompt": "Ēxƥàńď ƥŕōḿƥţ",
"collapsePrompt": "Ćōĺĺàƥśē ƥŕōḿƥţ",
- "scrollToPrompt": "Śćŕōĺĺ ţō ƥŕōḿƥţ",
"durationUnitSeconds": "ś",
"durationUnitMinutes": "ḿ",
"durationUnitHours": "ĥ",
diff --git a/apps/web/src/locales/pt-pt/_verbatim.json b/apps/web/src/locales/pt-pt/_verbatim.json
index bdc5cc9a7f..e64bd4a54e 100644
--- a/apps/web/src/locales/pt-pt/_verbatim.json
+++ b/apps/web/src/locales/pt-pt/_verbatim.json
@@ -6,5 +6,7 @@
"stats:summaryGit": "The only word is the tool name Git; the rest of the line is the interpolated value.",
"system:diskTotal": "\"Total\" is the ordinary Portuguese word; translating it would change nothing.",
"system:featureToggleExperimental": "\"Experimental\" is spelled identically in Portuguese.",
- "task:manual": "\"Manual\" is the ordinary Portuguese word for a non-automatic action."
+ "task:manual": "\"Manual\" is the ordinary Portuguese word for a non-automatic action.",
+ "task:promptHistoryPromptLabel": "Technical term kept in English across localized developer tooling.",
+ "task:promptHistoryPromptLabelGeneric": "Technical term kept in English across localized developer tooling."
}
diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json
index ad168e9e59..552e5fedde 100644
--- a/apps/web/src/locales/pt-pt/task.json
+++ b/apps/web/src/locales/pt-pt/task.json
@@ -1925,8 +1925,9 @@
"durationUnitSeconds": "s",
"expandPrompt": "Expandir prompt",
"promptHistory": "Histórico de prompts",
+ "promptHistoryPromptLabel": "Prompt {{number}}",
+ "promptHistoryPromptLabelGeneric": "Prompt",
"promptHistoryEmpty": "Ainda sem prompts.",
- "scrollToPrompt": "Ir para o prompt",
"launchErrorBaseBranchMissing": "Não foi possível iniciar a tarefa porque o branch base não está disponível.",
"launchErrorDefaultBranchUnresolved": "Não foi possível resolver o branch predefinido do repositório.",
"launchErrorPrAlreadyClosed": "A tarefa está associada a um pull request fechado.",
diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json
index 6574c01ba6..f91067cfea 100644
--- a/apps/web/src/locales/zh-cn/task.json
+++ b/apps/web/src/locales/zh-cn/task.json
@@ -1925,8 +1925,9 @@
"durationUnitSeconds": "秒",
"expandPrompt": "展开提示",
"promptHistory": "提示历史",
+ "promptHistoryPromptLabel": "提示 {{number}}",
+ "promptHistoryPromptLabelGeneric": "提示",
"promptHistoryEmpty": "暂无提示。",
- "scrollToPrompt": "滚动到提示",
"launchErrorBaseBranchMissing": "任务无法启动,因为基础分支不可用。",
"launchErrorDefaultBranchUnresolved": "无法解析仓库的默认分支。",
"launchErrorPrAlreadyClosed": "任务关联的拉取请求已关闭。",
diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json
index 76199ade65..abec48b75b 100644
--- a/apps/web/src/locales/zh-hk/task.json
+++ b/apps/web/src/locales/zh-hk/task.json
@@ -1925,8 +1925,9 @@
"durationUnitSeconds": "秒",
"expandPrompt": "展開提示",
"promptHistory": "提示歷史",
+ "promptHistoryPromptLabel": "提示 {{number}}",
+ "promptHistoryPromptLabelGeneric": "提示",
"promptHistoryEmpty": "暫無提示。",
- "scrollToPrompt": "滾動到提示",
"launchErrorBaseBranchMissing": "任務無法啓動,因為基礎分支不可用。",
"launchErrorDefaultBranchUnresolved": "無法解析儲存庫的預設分支。",
"launchErrorPrAlreadyClosed": "任務關聯的拉取要求已關閉。",
diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json
index 0bbda0804e..5e6ba7bb93 100644
--- a/apps/web/src/locales/zh-tw/task.json
+++ b/apps/web/src/locales/zh-tw/task.json
@@ -1925,8 +1925,9 @@
"durationUnitSeconds": "秒",
"expandPrompt": "展開提示",
"promptHistory": "提示歷史",
+ "promptHistoryPromptLabel": "提示 {{number}}",
+ "promptHistoryPromptLabelGeneric": "提示",
"promptHistoryEmpty": "暫無提示。",
- "scrollToPrompt": "滾動到提示",
"launchErrorBaseBranchMissing": "任務無法啟動,因為基礎分支不可用。",
"launchErrorDefaultBranchUnresolved": "無法解析儲存庫的預設分支。",
"launchErrorPrAlreadyClosed": "任務關聯的提取要求已關閉。",
diff --git a/docs/plans/prompt-alias-rendering/plan.md b/docs/plans/prompt-alias-rendering/plan.md
new file mode 100644
index 0000000000..ac542e2213
--- /dev/null
+++ b/docs/plans/prompt-alias-rendering/plan.md
@@ -0,0 +1,49 @@
+---
+status: pending
+initiative: prompt-alias-rendering
+requirements:
+ - docs/specs/ui/requirements/prompt-alias-rendering.md
+system_design:
+ - docs/specs/ui/system-design/prompt-alias-rendering.md
+---
+
+# Prompt alias rendering
+
+## Overview
+
+Make recognized saved-prompt `@name` aliases render consistently as prompt chips
+in the transcript, the desktop anchored last-prompt bar, and desktop/mobile
+Prompt history without changing prompt delivery or persistence.
+
+## Delivery order
+
+1. Extract the existing transcript prompt-chip renderer into a reusable UI
+ module, keeping the existing matcher and entity-reference composition intact.
+2. Wire the anchored last-prompt bar and Prompt history rows to that shared
+ renderer.
+3. Add focused component tests for recognized and unknown aliases in both
+ surfaces, then extend the existing chat/pinned/history E2E coverage with one
+ end-to-end alias assertion where the seeded prompt is visible.
+
+The work is one vertical frontend slice with no dependency on backend changes.
+
+## Work orders
+
+- [task-01-render-prompt-aliases.md](task-01-render-prompt-aliases.md)
+
+## Verification strategy
+
+- Focused Vitest coverage for the shared renderer, `ChatMessage`,
+ `AnchoredLastPromptBar`, and `PromptHistoryPanelContent`.
+- Existing prompt alias and last-prompt Playwright flows, extended to assert
+ the chip in the pinned and history surfaces.
+- Frontend typecheck and lint after the targeted tests pass.
+
+## Risks and exclusions
+
+- Prompt history currently uses a single-line text projection; the fix must not
+ replace that layout with a second Markdown layout or break overflow detection.
+- Pinned content has no message metadata, so it reuses alias rendering but does
+ not gain entity-reference chips.
+- No changes to alias parsing, backend expansion, prompt persistence, or raw
+ message rendering.
diff --git a/docs/plans/prompt-alias-rendering/task-01-render-prompt-aliases.md b/docs/plans/prompt-alias-rendering/task-01-render-prompt-aliases.md
new file mode 100644
index 0000000000..b3737e1c0b
--- /dev/null
+++ b/docs/plans/prompt-alias-rendering/task-01-render-prompt-aliases.md
@@ -0,0 +1,90 @@
+---
+id: task-01-render-prompt-aliases
+title: Render saved prompt aliases consistently
+status: pending
+wave: 1
+depends_on: []
+plan: docs/plans/prompt-alias-rendering/plan.md
+requirements:
+ - REQ-UI-PROMPT-ALIAS-001
+acceptance_criteria:
+ - AC-UI-PROMPT-ALIAS-001.1
+ - AC-UI-PROMPT-ALIAS-001.2
+ - AC-UI-PROMPT-ALIAS-001.3
+ - AC-UI-PROMPT-ALIAS-001.4
+ - AC-UI-PROMPT-ALIAS-001.5
+system_design: docs/specs/ui/system-design/prompt-alias-rendering.md
+---
+
+# Render saved prompt aliases consistently
+
+## Summary
+
+Reuse the transcript's saved-prompt chip presentation in the anchored last-
+prompt bar and Prompt history, with no change to persisted message content or
+agent delivery.
+
+## Scope
+
+- Extract the prompt-name subscription, prompt mention component factory, and
+ chip presentation from `apps/web/components/task/chat/messages/chat-message.tsx`
+ into a reusable module.
+- Keep `lib/prompts/prompt-mention-segments` as the only matching implementation.
+- Pass shared Markdown components to
+ `apps/web/components/task/chat/anchored-last-prompt-bar.tsx`.
+- Render recognized aliases through the shared text renderer in
+ `apps/web/components/task/prompt-history-panel-content.tsx` while preserving
+ its collapsed overflow measurement, expansion cap, navigation, and mobile
+ hit area.
+- Add focused Vitest assertions for recognized and unrecognized aliases in the
+ pinned and history surfaces; retain existing transcript assertions.
+- Extend the relevant existing Playwright coverage if the fixture can seed a
+ saved prompt without introducing a separate test-only data path.
+
+## Exclusions
+
+- Prompt parser, backend expansion, prompt persistence, APIs, WebSocket payloads,
+ entity-reference behavior, prompt numbers, and raw-message rendering.
+- General Markdown redesign or a second Prompt history layout.
+
+## Likely files
+
+- `apps/web/components/task/chat/messages/chat-message.tsx`
+- `apps/web/components/task/chat/messages/prompt-mention-components.tsx`
+- `apps/web/components/task/chat/messages/chat-message.test.tsx`
+- `apps/web/components/task/chat/anchored-last-prompt-bar.tsx`
+- `apps/web/components/task/chat/anchored-last-prompt-bar.test.tsx`
+- `apps/web/components/task/prompt-history-panel-content.tsx`
+- `apps/web/components/task/prompt-history-panel-content.test.tsx`
+- `apps/web/e2e/tests/chat/last-prompt-scroll.spec.ts`
+- `apps/web/e2e/tests/task/prompt-history-panel.spec.ts`
+
+## Targeted verification
+
+```text
+cd apps/web && pnpm exec vitest run \
+ components/task/chat/messages/chat-message.test.tsx \
+ components/task/chat/anchored-last-prompt-bar.test.tsx \
+ components/task/prompt-history-panel-content.test.tsx
+cd apps/web && pnpm run typecheck
+cd apps/web && pnpm exec eslint \
+ components/task/chat/messages/chat-message.tsx \
+ components/task/chat/messages/prompt-mention-components.tsx \
+ components/task/chat/anchored-last-prompt-bar.tsx \
+ components/task/prompt-history-panel-content.tsx
+```
+
+Run the focused Playwright spec only when the local E2E backend/profile is
+available:
+
+```text
+cd apps/web && pnpm exec playwright test \
+ e2e/tests/chat/last-prompt-scroll.spec.ts \
+ e2e/tests/task/prompt-history-panel.spec.ts
+```
+
+## Risks
+
+The history row is intentionally not converted to block Markdown: its one-line
+collapsed projection depends on measuring a text span. The shared text renderer
+must therefore produce the same chip nodes without changing row geometry.
diff --git a/docs/specs/ui/requirements/prompt-alias-rendering.md b/docs/specs/ui/requirements/prompt-alias-rendering.md
new file mode 100644
index 0000000000..cef27eebaa
--- /dev/null
+++ b/docs/specs/ui/requirements/prompt-alias-rendering.md
@@ -0,0 +1,66 @@
+---
+status: draft
+system: ui
+created: 2026-09-02
+owners:
+ - kandev
+---
+
+# Prompt Alias Rendering Requirements
+
+## Overview
+
+Saved prompts can be referenced in user messages with an `@name` alias. The
+transcript already presents recognized aliases as prompt chips, but the same
+message content is rendered differently in the anchored last-prompt bar and
+Prompt history. Consistent presentation lets users identify referenced prompt
+content regardless of which transcript surface they are reading.
+
+## Terminology
+
+- **Saved prompt alias:** An `@name` token recognized by the existing prompt
+ mention matcher and corresponding to a saved prompt in the prompt store.
+- **Prompt chip:** The transcript's visual representation of a recognized saved
+ prompt alias, including its hover preview when content is available.
+
+## Requirements
+
+### REQ-UI-PROMPT-ALIAS-001: Consistent saved prompt alias presentation
+
+**Intent:** Show recognized saved prompt aliases consistently across transcript
+surfaces without changing the message content sent to agents.
+
+**User story:** As a task reader, I want saved prompt aliases to look the same
+in the transcript, pinned last-prompt bar, and Prompt history, so that I can
+recognize referenced prompts while reviewing any surface.
+
+#### Acceptance criteria
+
+- **AC-UI-PROMPT-ALIAS-001.1:** When a user message contains an alias matching a
+ saved prompt, the transcript, anchored last-prompt bar, and Prompt history
+ shall render that alias as the same prompt chip, including its saved-prompt
+ name metadata and hover preview when the saved prompt has content.
+- **AC-UI-PROMPT-ALIAS-001.2:** When a user message contains an unrecognized
+ `@` token, each surface shall leave it as ordinary text using the existing
+ prompt-name matching rules; rendering shall not invent a chip for an unknown
+ name.
+- **AC-UI-PROMPT-ALIAS-001.3:** Alias chips shall continue to render inside the
+ existing Markdown structures supported by the transcript renderer. Rich
+ Markdown code spans and link destinations shall remain ordinary rendered
+ content, while aliases in link labels may use the chip's visual treatment
+ without creating nested interactive controls. The pinned and history surfaces
+ shall preserve their current compact, expandable, and scrollable behavior.
+- **AC-UI-PROMPT-ALIAS-001.4:** Updating the saved prompt collection shall update
+ alias chip recognition and hover content in mounted pinned or history views;
+ the fix shall not alter persisted message text, prompt expansion semantics, or
+ the raw-message view.
+- **AC-UI-PROMPT-ALIAS-001.5:** The presentation shall remain available on
+ desktop and phone Prompt history surfaces, while preserving the existing
+ desktop-only visibility rule for the anchored last-prompt bar.
+
+## Out of scope
+
+- Changing prompt alias parsing, matching, expansion depth, or agent delivery.
+- Changing saved prompt persistence, prompt CRUD, or message APIs.
+- Adding prompt numbers, navigation behavior, or new Markdown features.
+- Rendering aliases in passthrough, comments, plans, or unrelated editors.
diff --git a/docs/specs/ui/system-design/prompt-alias-rendering.md b/docs/specs/ui/system-design/prompt-alias-rendering.md
new file mode 100644
index 0000000000..9d1ccc5932
--- /dev/null
+++ b/docs/specs/ui/system-design/prompt-alias-rendering.md
@@ -0,0 +1,98 @@
+---
+status: draft
+system: ui
+requirements:
+ - REQ-UI-PROMPT-ALIAS-001
+created: 2026-09-02
+owners:
+ - kandev
+---
+
+# Prompt Alias Rendering System Design
+
+## Purpose and boundaries
+
+The UI system owns the presentation contract for saved prompt aliases across
+multiple task transcript surfaces. Prompt data and alias matching remain owned
+by the existing prompt store and `lib/prompts/prompt-mention-segments` helpers.
+Agent-facing prompt expansion and persistence are unchanged.
+
+## Requirement mapping
+
+| Requirement | Design section |
+| ------------------------- | -------------------------------------------------------------------------------------------------- |
+| `REQ-UI-PROMPT-ALIAS-001` | [Components and responsibilities](#components-and-responsibilities), [Control flow](#control-flow) |
+
+## Components and responsibilities
+
+- `lib/prompts/prompt-mention-segments` remains the single matcher. It builds
+ normalized prompt names and splits text into ordinary and recognized prompt
+ segments.
+- A shared prompt-mention presentation module under
+ `apps/web/components/task/chat/messages/` owns the prompt chip, prompt lookup,
+ and Markdown component factory currently embedded in `chat-message.tsx`.
+ It exposes both the Markdown components used by rich transcript content and a
+ text-segment renderer for single-line Prompt history rows.
+- `ChatMessage` consumes the shared factory for the existing transcript user
+ bubble. Its entity-reference component composition remains transcript-owned.
+- `AnchoredLastPromptBar` passes the shared prompt components to
+ `MemoizedMarkdown` after stripping system tags, preserving the existing
+ Markdown renderer and height/overflow behavior.
+- `PromptHistoryPanelContent` uses the shared text-segment renderer in both
+ the collapsed and expanded row content. The row keeps its current text span,
+ truncation measurement, expansion cap, navigation, and touch sizing. Content-
+ bearing chips remain keyboard-focusable and intercept activation so keyboard
+ and touch preview access does not navigate the row. Chips rendered inside
+ Markdown links are visual-only, avoiding nested interactive semantics while
+ preserving link activation.
+- `MemoizedMarkdown` remains the common Markdown renderer and continues to
+ normalize content through its existing cache. The change does not add raw HTML
+ or alter the Markdown safety policy.
+
+## Data and contracts
+
+No backend, HTTP, WebSocket, or persistence contract changes. The shared UI
+renderer reads `state.prompts.items`, matching the existing transcript path.
+Recognized aliases are represented by the existing `custom-prompt-mention`
+test ID and `data-prompt-name` attribute. Unknown aliases remain text.
+
+The shared Markdown factory accepts optional entity-reference components so the
+transcript can preserve its current entity chip behavior, while pinned content
+(which has no message metadata) and history rows use the empty entity-reference
+set.
+
+## Control flow
+
+1. Each mounted surface derives prompt names from the current prompt store.
+2. The shared matcher identifies only recognized aliases using the existing
+ boundary and name rules, excluding code spans and link destinations.
+3. Rich Markdown surfaces pass the shared component map to `MemoizedMarkdown`,
+ which injects prompt chips into supported Markdown block children.
+4. Prompt history passes each plain row text through the shared segment renderer,
+ preserving the row's single-line CSS measurement and expanded layout.
+5. A chip looks up its current saved prompt by name. Existing non-empty prompts
+ receive the hover preview; empty or missing content receives the existing
+ title-only chip.
+6. Store updates invalidate the derived names/components through React store
+ subscriptions, so mounted views reconcile without changing message data.
+
+## Failure and recovery
+
+If the prompt store is not loaded or a name is absent, the alias is rendered as
+ordinary text or a title-only chip according to the existing transcript behavior.
+The renderer never modifies or expands persisted message content. If a surface
+has no prompt text, its current empty state remains unchanged.
+
+## Security
+
+The change only reuses the existing prompt chip and `MemoizedMarkdown` safety
+path. Prompt content shown in hover previews remains the same content already
+available to the authenticated prompt store. No new HTML, URL, or navigation
+handling is introduced.
+
+## Observability
+
+No new runtime metrics or logs are required. Unit coverage will assert shared
+recognized/unknown behavior and surface-specific rendering; existing anchored
+bar, ChatMessage, and Prompt history E2E coverage remains the behavioral smoke
+signal.