Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
} from "../../constants";
import type { ChatForkTarget } from "../../types";
import { pageLinkFinder } from "../../utils/pageLinks";
import { PinnedPrompt } from "./components/PinnedPrompt";
import { TranscriptFindBar } from "./components/TranscriptFindBar";
import { TranscriptRowItem } from "./components/TranscriptRowItem";
import { useCarriedReaderPosition } from "./hooks/useCarriedReaderPosition";
Expand All @@ -52,6 +53,7 @@ import { useNestedItems } from "./providers/NestedItemsProvider";
import { useTranscriptPlugins } from "./providers/TranscriptPluginsProvider";
import { copySourceText } from "./utils/copySourceText";
import { galleryImages } from "./utils/galleryImages";
import { lastPrompt } from "./utils/lastPrompt";
import { replyFooters } from "./utils/replyFooters";
import { subagentLines } from "./utils/subagentLines";
import {
Expand Down Expand Up @@ -251,6 +253,8 @@ export function Transcript({
}, [rows, entryOverrides]);

const gallery = useMemo(() => galleryImages(rows), [rows]);
const harness = snapshot.session?.harness;
const prompt = useMemo(() => lastPrompt(rows, harness), [rows, harness]);
const availableCommands = snapshot.session?.availableCommands;
const commands = useMemo(
() => new Map(availableCommands?.map((command) => [command.name, command])),
Expand Down Expand Up @@ -327,6 +331,11 @@ export function Transcript({
},
});
const { holdRevealUntilScrollEnds } = transcriptWindow;
const promptIndex = prompt
? rows.findIndex((row) => row.key === prompt.key)
: -1;
const promptDroppedByWindow =
promptIndex !== -1 && promptIndex < transcriptWindow.startIndex;

useEffect(() => {
if (revealMessageId === null) return;
Expand Down Expand Up @@ -377,7 +386,8 @@ export function Transcript({
seenRowKeys.has(row.key) ||
row.key === find.currentRowKey ||
row.key === revealRowKey ||
row.key === carriedRowKey
row.key === carriedRowKey ||
row.key === prompt?.key
}
row={row}
rowRef={transcriptWindow.rowRef(row.key)}
Expand All @@ -389,6 +399,15 @@ export function Transcript({
return (
<MessageScroller.Root className="relative flex min-h-0 min-w-0 flex-1 flex-col">
<ChatImageGalleryProvider images={gallery}>
{/* The find bar takes the same corner, and aligns its hits to the same edge. */}
{!find.isOpen && (
<PinnedPrompt
droppedByWindow={promptDroppedByWindow}
messageId={prompt?.messageId ?? null}
text={prompt?.text ?? ""}
viewport={viewport}
/>
)}
<TranscriptFindBar
currentIndex={find.currentIndex}
inputRef={find.inputRef}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { cn } from "@superset/ui/utils";
import {
CHAT_COLUMN_CLASSNAME,
CHAT_GUTTER_CLASSNAME,
} from "../../../../constants";
import { useScrolledPast } from "./hooks/useScrolledPast";

/** It repeats a message already in the transcript, so it is hidden from readers and the pointer. */
export function PinnedPrompt({
droppedByWindow,
messageId,
text,
viewport,
}: {
/** The transcript window no longer renders the row, so it is above the fold. */
droppedByWindow: boolean;
messageId: string | null;
text: string;
viewport: HTMLElement | null;
}) {
const scrolledPast = useScrolledPast(viewport, messageId, droppedByWindow);
if (!scrolledPast) return null;
return (
<div
aria-hidden="true"
className={cn(
CHAT_GUTTER_CLASSNAME,
"pointer-events-none absolute inset-x-0 top-0 z-10 border-border/60 border-b bg-background/85 backdrop-blur-sm",
)}
>
<div className={cn(CHAT_COLUMN_CLASSNAME, "flex justify-end px-4 py-2")}>
<span className="max-w-[min(100%,36rem)] truncate rounded-full bg-foreground/10 px-3 py-1.5 font-sans text-sm">
{text}
</span>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useScrolledPast } from "./useScrolledPast";
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
import { rowElement } from "../../../../utils/rowElement";

/** Everything below the viewport intersects, so an instant scroll past it still reports. */
const BELOW_VIEWPORT_MARGIN = "0px 0px 1000000px 0px";

/**
* Whether the message has scrolled above the top edge of the viewport. A row
* the transcript window dropped is behind the spacer, which only covers rows
* already scrolled past, so it counts without an element to observe.
*/
export function useScrolledPast(
viewport: HTMLElement | null,
messageId: string | null,
droppedByWindow: boolean,
): boolean {
const [scrolledPast, setScrolledPast] = useState(false);
useEffect(() => {
if (droppedByWindow) {
setScrolledPast(true);
return;
}
setScrolledPast(false);
if (!viewport || messageId === null) return;
if (typeof IntersectionObserver === "undefined") return;
const element = rowElement(viewport, messageId);
if (!element) return;
const observer = new IntersectionObserver(
(entries) => {
const entry = entries.at(-1);
if (entry) setScrolledPast(!entry.isIntersecting);
},
{ root: viewport, rootMargin: BELOW_VIEWPORT_MARGIN, threshold: 0 },
);
observer.observe(element);
return () => observer.disconnect();
}, [viewport, messageId, droppedByWindow]);
return scrolledPast;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { PinnedPrompt } from "./PinnedPrompt";
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { type LastPrompt, lastPrompt } from "./lastPrompt";
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { expect, test } from "bun:test";
import type { TranscriptRow } from "../transcriptRows";
import { lastPrompt } from "./lastPrompt";

function userRow(key: string, text: string): TranscriptRow {
return {
kind: "item",
key,
groupStart: true,
item: {
id: `item-${key}`,
kind: "user_message",
startedAtMs: 1,
content: [{ type: "text", text }],
},
};
}

function replyRow(key: string): TranscriptRow {
return {
kind: "item",
key,
groupStart: false,
item: {
id: `item-${key}`,
kind: "agent_message",
startedAtMs: 1,
text: key,
},
} as TranscriptRow;
}

function queuedRow(key: string, text: string): TranscriptRow {
return {
kind: "outbox",
key,
groupStart: true,
entry: { clientId: key, content: [{ type: "text", text }] },
} as TranscriptRow;
}

test("takes the newest prompt, as one line without its attachment tags", () => {
const rows = [
userRow("u1", "fix it"),
replyRow("r1"),
userRow(
"u2",
'read this\n<attachment path=".paradev/attachments/a.png" type="image/png" />\nplease',
),
replyRow("r2"),
];
expect(lastPrompt(rows, "claude-acp")).toEqual({
key: "u2",
messageId: "item-u2",
text: "read this please",
});
});

test("skips bookkeeping the harness injected, and a queued prompt", () => {
const rows = [
userRow("u1", "fix it"),
replyRow("r1"),
userRow("u2", "<system-reminder>be nice</system-reminder>"),
queuedRow("q1", "and then deploy"),
];
expect(lastPrompt(rows, "claude-acp")).toEqual({
key: "u1",
messageId: "item-u1",
text: "fix it",
});
});

test("pins nothing rather than an older prompt when the newest has no line", () => {
const rows = [
userRow("u1", "fix it"),
replyRow("r1"),
userRow(
"u2",
'<attachment path=".paradev/attachments/a.png" type="image/png" />',
),
replyRow("r2"),
];
expect(lastPrompt(rows, "claude-acp")).toBeNull();
});

test("falls back to the pasted text of a message that is only a paste", () => {
const rows = [
userRow(
"u1",
'<pasted_text lines="2">\nline one\nline two\n</pasted_text>',
),
replyRow("r1"),
];
expect(lastPrompt(rows, "claude-acp")?.text).toBe("line one line two");
});

test("has nothing to pin without a prompt", () => {
expect(lastPrompt([replyRow("r1")], "claude-acp")).toBeNull();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {
parseAttachmentTags,
readBookkeeping,
userMessageText,
} from "@superset/chat/core";
import type { UserMessage } from "@superset/chat/protocol";
import { rowMessageId, type TranscriptRow } from "../transcriptRows";

export type LastPrompt = { key: string; messageId: string; text: string };

function oneLine(text: string): string {
return text.replace(/\s+/g, " ").trim();
}

/** A message with no line of its own pins nothing: an older prompt would caption the wrong reply. */
export function lastPrompt(
rows: readonly TranscriptRow[],
harness: string | undefined,
): LastPrompt | null {
for (let index = rows.length - 1; index >= 0; index--) {
const row = rows[index];
if (!row || row.kind !== "item" || row.item.kind !== "user_message") {
continue;
}
const raw = userMessageText(row.item as UserMessage);
if (readBookkeeping(harness, raw)) continue;
const { text, pastedTexts } = parseAttachmentTags(raw);
const line = oneLine(text) || oneLine(pastedTexts[0] ?? "");
if (line.length === 0) return null;
return { key: row.key, messageId: rowMessageId(row), text: line };
}
return null;
}
Loading