diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/components/TranscriptRowItem/TranscriptRowItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/components/TranscriptRowItem/TranscriptRowItem.tsx index e730409b9ea..d69549b3d51 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/components/TranscriptRowItem/TranscriptRowItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/components/TranscriptRowItem/TranscriptRowItem.tsx @@ -4,6 +4,7 @@ import { TurnGroupSection, type TurnGroupSectionProps, } from "../TurnGroupSection"; +import { sameRowSnapshot } from "./utils/sameRowSnapshot"; type TranscriptRowItemProps = TurnGroupSectionProps & { className: string; @@ -34,7 +35,11 @@ function sameRow( const keys = Object.keys(next) as (keyof TranscriptRowItemProps)[]; return ( keys.length === Object.keys(previous).length && - keys.every((key) => Object.is(previous[key], next[key])) + keys.every((key) => + key === "snapshot" + ? sameRowSnapshot(next.row, previous.snapshot, next.snapshot) + : Object.is(previous[key], next[key]), + ) ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/components/TranscriptRowItem/utils/sameRowSnapshot/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/components/TranscriptRowItem/utils/sameRowSnapshot/index.ts new file mode 100644 index 00000000000..46fe8e79409 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/components/TranscriptRowItem/utils/sameRowSnapshot/index.ts @@ -0,0 +1 @@ +export { sameRowSnapshot } from "./sameRowSnapshot"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/components/TranscriptRowItem/utils/sameRowSnapshot/sameRowSnapshot.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/components/TranscriptRowItem/utils/sameRowSnapshot/sameRowSnapshot.test.ts new file mode 100644 index 00000000000..6ddd8246fb3 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/components/TranscriptRowItem/utils/sameRowSnapshot/sameRowSnapshot.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test"; +import { reduceMany, type SessionSnapshot } from "@superset/chat/core"; +import type { Envelope, Item, ToolCall } from "@superset/chat/protocol"; +import type { TranscriptRow } from "../../../../utils/transcriptRows"; +import { sameRowSnapshot } from "./sameRowSnapshot"; + +const reply: Item = { + id: "a-1", + kind: "agent_message", + startedAtMs: 1, + completedAtMs: 2, + text: "Done.", +}; +const streaming: Item = { + id: "a-2", + kind: "agent_message", + startedAtMs: 3, + text: "", +}; +const command: ToolCall = { + id: "tool-1", + kind: "tool_call", + startedAtMs: 4, + title: "bun test", + toolKind: "execute", + toolName: "bash", + status: "running", + content: [], +}; +const approval = { + id: "approval-1", + kind: "approval_request", + startedAtMs: 5, + title: "bun test", + targetItemId: command.id, + status: "pending", + options: [], +} as unknown as Item; +const thinking: Item = { + id: "r-1", + kind: "reasoning", + startedAtMs: 6, + text: "", +}; + +function snapshotOf(items: readonly Item[], harness = "acp"): SessionSnapshot { + return { + session: { status: "running", harness, backgroundTasks: [] }, + turns: new Map(), + items: new Map(items.map((item) => [item.id, { item, turnId: "t1" }])), + liveStreams: new Map(), + cursor: { epoch: "e", seq: 1 }, + pendingReset: null, + } as unknown as SessionSnapshot; +} + +function streamInto(snapshot: SessionSnapshot, itemId: string, append: string) { + return reduceMany(snapshot, [ + { + cursor: { epoch: "e", seq: 2 }, + delta: { type: "text", itemId, append }, + } as unknown as Envelope, + ]); +} + +function itemRow(item: Item): TranscriptRow { + return { kind: "item", key: item.id, groupStart: false, item }; +} + +const toolRun: TranscriptRow = { + kind: "tool_run", + key: "run-1", + groupStart: false, + items: [command], + steps: [command, thinking], + defaultCollapsed: false, +}; + +describe("sameRowSnapshot", () => { + const items = [reply, streaming, command, approval, thinking]; + const base = snapshotOf(items); + + test("another message streaming leaves a settled row alone", () => { + expect( + sameRowSnapshot(itemRow(reply), base, streamInto(base, "a-2", "Hi")), + ).toBe(true); + }); + + test("the row's own text streaming changes it", () => { + expect( + sameRowSnapshot(itemRow(streaming), base, streamInto(base, "a-2", "Hi")), + ).toBe(false); + }); + + test("a tool run changes when its reasoning step streams", () => { + const next = streamInto(base, thinking.id, "Checking"); + expect(sameRowSnapshot(toolRun, base, next)).toBe(false); + expect(sameRowSnapshot(toolRun, base, streamInto(base, "a-2", "Hi"))).toBe( + true, + ); + }); + + test("an approval row follows changes to its target", () => { + const finished: ToolCall = { ...command, status: "completed" }; + const next = snapshotOf([reply, streaming, finished, approval, thinking]); + expect(sameRowSnapshot(itemRow(approval), base, next)).toBe(false); + expect(sameRowSnapshot(itemRow(reply), base, next)).toBe(true); + }); + + test("every row changes when the harness does", () => { + expect( + sameRowSnapshot(itemRow(reply), base, snapshotOf(items, "codex")), + ).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/components/TranscriptRowItem/utils/sameRowSnapshot/sameRowSnapshot.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/components/TranscriptRowItem/utils/sameRowSnapshot/sameRowSnapshot.ts new file mode 100644 index 00000000000..200853ad4fa --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/components/TranscriptRowItem/utils/sameRowSnapshot/sameRowSnapshot.ts @@ -0,0 +1,20 @@ +import type { SessionSnapshot } from "@superset/chat/core"; +import { rowSnapshotView } from "../../../../utils/rowSnapshotView"; +import type { TranscriptRow } from "../../../../utils/transcriptRows"; + +export function sameRowSnapshot( + row: TranscriptRow, + previous: SessionSnapshot, + next: SessionSnapshot, +): boolean { + if (previous === next) return true; + const before = rowSnapshotView(row, previous); + const after = rowSnapshotView(row, next); + return ( + before.harness === after.harness && + before.asker === after.asker && + before.approvalTarget === after.approvalTarget && + before.texts.length === after.texts.length && + before.texts.every((text, index) => text === after.texts[index]) + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/components/TurnGroupSection/TurnGroupSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/components/TurnGroupSection/TurnGroupSection.tsx index c77848eb27a..b272e4aee6a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/components/TurnGroupSection/TurnGroupSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/components/TurnGroupSection/TurnGroupSection.tsx @@ -1,8 +1,6 @@ import { useLingui } from "@lingui/react/macro"; import type { OutboxEntry, SessionSnapshot } from "@superset/chat/core"; -import { approvalAsker, displayText } from "@superset/chat/core"; import type { - ApprovalRequest, AvailableCommand, Decision, Item, @@ -14,6 +12,7 @@ import type { ReactNode } from "react"; import type { ChatForkTarget } from "../../../../types"; import type { PageLink } from "../../../../utils/pageLinks"; import { PageLinkCard } from "../../../PageLinkCard"; +import { rowSnapshotView } from "../../utils/rowSnapshotView"; import type { TranscriptRow } from "../../utils/transcriptRows"; import { AttachmentRail, type RailImage } from "../AttachmentRail"; import { ReplyFooter } from "../ReplyFooter"; @@ -59,15 +58,6 @@ function imageRail(tools: readonly ToolCall[]): ReactNode { ) : null; } -function approvalTarget( - snapshot: SessionSnapshot, - item: Item, -): Item | undefined { - if (item.kind !== "approval_request") return undefined; - const targetId = (item as ApprovalRequest).targetItemId; - return targetId ? snapshot.items.get(targetId)?.item : undefined; -} - export type TurnGroupSectionProps = { row: TranscriptRow; lastReply: boolean; @@ -104,7 +94,10 @@ export function TurnGroupSection({ snapshot, }: TurnGroupSectionProps) { const { t } = useLingui(); - const harness = snapshot.session?.harness; + const { harness, texts, asker, approvalTarget } = rowSnapshotView( + row, + snapshot, + ); switch (row.kind) { case "working": return ( @@ -115,12 +108,11 @@ export function TurnGroupSection({ /> ); case "item": { - const asker = approvalAsker(snapshot.items, row.item); return ( <> {row.item.kind === "tool_call" && imageRail([row.item as ToolCall])} {pageCards(row.pages)} @@ -166,9 +158,7 @@ export function TurnGroupSection({ onToggle={onToggleEntry} rowKey={row.key} steps={row.steps} - stepTexts={row.steps.map((step) => - step.kind === "reasoning" ? displayText(snapshot, step.id) : "", - )} + stepTexts={texts} /> {imageRail(row.items)} {pageCards(row.pages)} @@ -199,7 +189,7 @@ export function TurnGroupSection({ className="hover:opacity-100" item={row.item} onFork={onFork} - text={displayText(snapshot, row.item.id)} + text={texts[0] ?? ""} /> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/utils/rowSnapshotView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/utils/rowSnapshotView/index.ts new file mode 100644 index 00000000000..34b68617ec1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/utils/rowSnapshotView/index.ts @@ -0,0 +1 @@ +export { type RowSnapshotView, rowSnapshotView } from "./rowSnapshotView"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/utils/rowSnapshotView/rowSnapshotView.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/utils/rowSnapshotView/rowSnapshotView.ts new file mode 100644 index 00000000000..1f02c4f90c5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/utils/rowSnapshotView/rowSnapshotView.ts @@ -0,0 +1,63 @@ +import { + approvalAsker, + displayText, + type SessionSnapshot, +} from "@superset/chat/core"; +import type { ApprovalRequest, Item, ToolCall } from "@superset/chat/protocol"; +import type { TranscriptRow } from "../transcriptRows"; + +export type RowSnapshotView = { + harness: string | undefined; + /** Per item for item and footer rows, per step for tool runs. */ + texts: readonly string[]; + asker: ToolCall | undefined; + approvalTarget: Item | undefined; +}; + +function approvalTarget( + snapshot: SessionSnapshot, + item: Item, +): Item | undefined { + if (item.kind !== "approval_request") return undefined; + const targetId = (item as ApprovalRequest).targetItemId; + return targetId ? snapshot.items.get(targetId)?.item : undefined; +} + +export function rowSnapshotView( + row: TranscriptRow, + snapshot: SessionSnapshot, +): RowSnapshotView { + const harness = snapshot.session?.harness; + switch (row.kind) { + case "item": + return { + harness, + texts: [displayText(snapshot, row.item.id)], + asker: approvalAsker(snapshot.items, row.item), + approvalTarget: approvalTarget(snapshot, row.item), + }; + case "reply_footer": + return { + harness, + texts: [displayText(snapshot, row.item.id)], + asker: undefined, + approvalTarget: undefined, + }; + case "tool_run": + return { + harness, + texts: row.steps.map((step) => + step.kind === "reasoning" ? displayText(snapshot, step.id) : "", + ), + asker: undefined, + approvalTarget: undefined, + }; + default: + return { + harness, + texts: [], + asker: undefined, + approvalTarget: undefined, + }; + } +}