Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

365 lines
10 KiB
TypeScript

import { useLayoutEffect, useMemo, useRef } from "react";
import type { AgentConversationVM } from "../contracts/agentConversationVM";
import { agentTranscriptRowHasPresentationKind } from "../projection/agentTranscriptPresentation";
import { normalizeAgentTitleText } from "../../utils/agentTitleText.ts";
export interface AgentTranscriptTurnGroup {
key: string;
turnId: string | null;
rows: Array<{
row: AgentConversationVM["rows"][number];
rowIndex: number;
}>;
}
export interface AgentMessageLocatorItem {
hasAgentResponse: boolean;
key: string;
rowKey: string;
turnGroupIndex: number;
rowIndex: number;
summary: string;
}
export interface AgentParticipantTurnProjection {
dividerRowIndexes: ReadonlySet<number>;
turnIndexByRowIndex: ReadonlyMap<number, number>;
}
export function useEnteringTranscriptRows(
rowKeys: string[]
): ReadonlySet<string> {
const previousKeysRef = useRef<Set<string> | null>(null);
const previousKeys = previousKeysRef.current;
const enteringRowKeys = new Set<string>();
if (previousKeys) {
for (const key of rowKeys) {
if (!previousKeys.has(key)) {
enteringRowKeys.add(key);
}
}
}
useLayoutEffect(() => {
previousKeysRef.current = new Set(rowKeys);
}, [rowKeys]);
return enteringRowKeys;
}
export function transcriptRowKey(
row: AgentConversationVM["rows"][number]
): string {
if (row.kind === "tool-group") {
return row.expansionKey ?? row.id;
}
return row.id;
}
export function isAssistantParticipantContentRow(
row: AgentConversationVM["rows"][number]
): boolean {
switch (row.kind) {
case "message":
return row.speaker === "assistant";
case "generated-image":
case "processing":
case "tool-group":
case "turn-summary":
return true;
case "goal-control":
return false;
}
}
export function findLastMessageRowIndex(
rows: readonly {
row: AgentConversationVM["rows"][number];
rowIndex: number;
}[]
): number | null {
for (let index = rows.length - 1; index >= 0; index -= 1) {
const entry = rows[index];
if (
entry?.row.kind === "message" &&
entry.row.speaker === "assistant" &&
entry.row.messages.length > 0
) {
return entry.rowIndex;
}
}
return null;
}
export function buildAgentTranscriptTurnGroups(
rows: ReadonlyArray<AgentConversationVM["rows"][number]>,
rowKeys: ReadonlyArray<string>
): AgentTranscriptTurnGroup[] {
const groups: AgentTranscriptTurnGroup[] = [];
let currentGroup: AgentTranscriptTurnGroup | null = null;
rows.forEach((row, rowIndex) => {
const turnId = transcriptPresentationTurnId(
rows,
rowIndex,
currentGroup?.turnId ?? null
);
if (!currentGroup || currentGroup.turnId !== turnId) {
currentGroup = {
key: turnId ?? `orphan:${rowKeys[rowIndex] ?? transcriptRowKey(row)}`,
turnId,
rows: []
};
groups.push(currentGroup);
}
currentGroup.rows.push({ row, rowIndex });
});
return groups;
}
function transcriptPresentationTurnId(
rows: ReadonlyArray<AgentConversationVM["rows"][number]>,
rowIndex: number,
currentTurnId: string | null
): string | null {
const rowTurnId = rows[rowIndex]?.turnId ?? null;
if (rowTurnId || !currentTurnId) {
return rowTurnId;
}
const nextTurnId =
rows.slice(rowIndex + 1).find((candidate) => candidate.turnId !== null)
?.turnId ?? null;
// A session-level row can occur chronologically inside a live Turn. Keep it
// in that Turn's presentation group only when the next lifecycle-owned row
// proves the surrounding Turn is unchanged; the row itself stays turnless.
return nextTurnId === currentTurnId ? currentTurnId : null;
}
export function buildTurnGroupIndexByRowIndex(
turnGroups: readonly AgentTranscriptTurnGroup[]
): ReadonlyMap<number, number> {
const rowIndexToTurnGroupIndex = new Map<number, number>();
turnGroups.forEach((group, groupIndex) => {
group.rows.forEach(({ rowIndex }) => {
rowIndexToTurnGroupIndex.set(rowIndex, groupIndex);
});
});
return rowIndexToTurnGroupIndex;
}
export function buildUserMessageLocatorItems(
rows: ReadonlyArray<AgentConversationVM["rows"][number]>,
rowKeys: ReadonlyArray<string>,
turnGroupIndexByRowIndex: ReadonlyMap<number, number>
): AgentMessageLocatorItem[] {
const items: AgentMessageLocatorItem[] = [];
rows.forEach((row, rowIndex) => {
if (row.kind !== "message" || row.speaker !== "user") {
return;
}
const summary = summarizeUserMessageRow(row);
if (!summary) {
return;
}
const rowKey = rowKeys[rowIndex] ?? transcriptRowKey(row);
items.push({
hasAgentResponse: hasAgentResponseForTurn(rows, row, rowIndex),
key: `user-message:${rowKey}`,
rowKey,
turnGroupIndex: turnGroupIndexByRowIndex.get(rowIndex) ?? rowIndex,
rowIndex,
summary
});
});
return items;
}
export function hasAgentResponseForTurn(
rows: ReadonlyArray<AgentConversationVM["rows"][number]>,
userRow: AgentConversationVM["rows"][number],
userRowIndex: number
): boolean {
const turnId = userRow.turnId ?? null;
for (let index = userRowIndex + 1; index < rows.length; index += 1) {
const row = rows[index];
if (!row) {
continue;
}
if (row.kind === "generated-image") {
return !turnId || row.turnId === turnId;
}
if (row.kind !== "message") {
continue;
}
if (row.speaker === "user") {
return false;
}
if (turnId && row.turnId !== turnId) {
return false;
}
if (row.speaker === "assistant") {
return true;
}
}
return false;
}
export function summarizeUserMessageRow(
row: Extract<AgentConversationVM["rows"][number], { kind: "message" }>
): string {
return normalizeLocatorSummary(
row.messages
.map((message) => {
if (message.contentKind === "selected-text") {
return message.selectedText?.texts.join(" ") ?? "";
}
return message.copyText ?? message.body;
})
.join(" ")
);
}
export function normalizeLocatorSummary(value: string): string {
return normalizeAgentTitleText(value);
}
export function escapeCssString(value: string): string {
return value.replace(/["\\]/g, "\\$&");
}
export function findTurnDividerRowIndexes(
turnIndexById: ReadonlyMap<string, number>,
rows: ReadonlyArray<AgentConversationVM["rows"][number]>
): ReadonlySet<number> {
const dividerRowIndexes = new Set<number>();
const previousTurnIds = new Set<string>();
rows.forEach((row, rowIndex) => {
const currentTurnId = row.turnId ?? null;
if (!currentTurnId) {
return;
}
const turnIndex = turnIndexById.get(currentTurnId) ?? -1;
const previousTurnId = rows[rowIndex - 1]?.turnId ?? null;
if (
rowIndex > 0 &&
turnIndex > 0 &&
previousTurnId &&
previousTurnId !== currentTurnId &&
!agentTranscriptRowHasPresentationKind(
rows[rowIndex - 1],
"turn-boundary"
) &&
!previousTurnIds.has(currentTurnId)
) {
dividerRowIndexes.add(rowIndex);
}
previousTurnIds.add(currentTurnId);
});
return dividerRowIndexes;
}
/**
* Participant-header presentation (Agent board session detail): a presentation
* turn starts at each user message and continues until the next user message.
* Canonical Turn ids deliberately do not participate because recovery or
* provider continuation may split one visible reply across multiple Turns.
*/
export function buildAgentParticipantTurnProjection(
rows: ReadonlyArray<AgentConversationVM["rows"][number]>
): AgentParticipantTurnProjection {
const dividerRowIndexes = new Set<number>();
const turnIndexByRowIndex = new Map<number, number>();
let turnIndex = 0;
rows.forEach((row, rowIndex) => {
if (rowIndex > 0 && row.kind === "message" && row.speaker === "user") {
turnIndex += 1;
dividerRowIndexes.add(rowIndex);
}
turnIndexByRowIndex.set(rowIndex, turnIndex);
});
return {
dividerRowIndexes,
turnIndexByRowIndex
};
}
/**
* Participant-header presentation: standalone tool-group rows belong to the
* work that produced the NEXT assistant message, so they attach to that
* message (rendered inside its block) instead of sitting after the previous
* one. Trailing tool rows with no following assistant message stay standalone.
*/
export function attachLeadingToolRowsToFollowingMessages(
rows: ReadonlyArray<AgentConversationVM["rows"][number]>
): AgentConversationVM["rows"] {
const result: AgentConversationVM["rows"] = [];
let pendingToolRows: Extract<
AgentConversationVM["rows"][number],
{ kind: "tool-group" }
>[] = [];
for (const row of rows) {
if (row.kind === "tool-group") {
pendingToolRows.push(row);
continue;
}
if (row.kind === "message" && row.speaker === "assistant") {
if (pendingToolRows.length > 0) {
result.push({
...row,
leadingToolRows: [...(row.leadingToolRows ?? []), ...pendingToolRows]
});
pendingToolRows = [];
continue;
}
result.push(row);
continue;
}
if (pendingToolRows.length > 0) {
result.push(...pendingToolRows);
pendingToolRows = [];
}
result.push(row);
}
result.push(...pendingToolRows);
return result;
}
/**
* Read hook owning the display-row projection for the transcript view: in
* participant-header mode tool-group rows attach to the following assistant
* message, presentation turns start at user messages, and row keys derive from
* the same pass. Keeping the memoization in this model module (next to
* `useEnteringTranscriptRows`) keeps the view component within the
* degradation-check memoization budget.
*/
export function useAgentTranscriptDisplayRows(
rows: ReadonlyArray<AgentConversationVM["rows"][number]>,
participantHeadersEnabled: boolean
): {
rows: ReadonlyArray<AgentConversationVM["rows"][number]>;
rowKeys: string[];
participantTurnProjection: AgentParticipantTurnProjection | null;
} {
return useMemo(() => {
const displayRows = participantHeadersEnabled
? attachLeadingToolRowsToFollowingMessages(rows)
: rows;
return {
rows: displayRows,
rowKeys: displayRows.map(transcriptRowKey),
participantTurnProjection: participantHeadersEnabled
? buildAgentParticipantTurnProjection(displayRows)
: null
};
}, [rows, participantHeadersEnabled]);
}