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
283 lines
8.2 KiB
TypeScript
283 lines
8.2 KiB
TypeScript
import type { WorkspaceAgentActivityTimelineItem } from "../../workspaceAgentTimelineTypes";
|
|
import type {
|
|
WorkspaceAgentSessionDetailMessage,
|
|
WorkspaceAgentSessionDetailThinking,
|
|
WorkspaceAgentSessionDetailTurn
|
|
} from "../../workspaceAgentSessionDetailViewModel";
|
|
import type {
|
|
AgentMessageContentVM,
|
|
AgentMessageRowVM,
|
|
AgentThinkingContentVM
|
|
} from "../contracts/agentMessageRowVM";
|
|
import type { AgentToolCallVM } from "../contracts/agentToolCallVM";
|
|
import { isApprovalToolCall } from "./agentToolRendererKind";
|
|
import { resolveAgentTranscriptPresentationKind } from "./agentTranscriptPresentation";
|
|
import { projectAgentToolCall } from "./agentToolProjection";
|
|
import {
|
|
isCollaborationTimelineItem,
|
|
projectAgentCollaborationVM
|
|
} from "./agentCollaborationProjection";
|
|
import { projectConversationUserRow } from "./agentConversationUserProjection";
|
|
import { parseTuttiPlanIssueLink } from "../tuttiModePlanIssueLinkMarker";
|
|
|
|
export type AgentTurnSequenceItemVM =
|
|
| {
|
|
kind: "user-message";
|
|
row: AgentMessageRowVM;
|
|
}
|
|
| {
|
|
kind: "assistant-message";
|
|
message: AgentMessageContentVM;
|
|
}
|
|
| {
|
|
kind: "thinking";
|
|
thinking: AgentThinkingContentVM;
|
|
}
|
|
| {
|
|
kind: "tool-call";
|
|
call: AgentToolCallVM;
|
|
};
|
|
|
|
export function buildAgentTurnSequenceItems(
|
|
turn: WorkspaceAgentSessionDetailTurn,
|
|
workspaceId?: string | null
|
|
): AgentTurnSequenceItemVM[] {
|
|
const items = turn.rawAgentItems ?? turn.agentItems;
|
|
const sequence: AgentTurnSequenceItemVM[] = turn.userMessages.map(
|
|
(message) => ({
|
|
kind: "user-message",
|
|
row: projectConversationUserRow(message, turn.id, workspaceId)
|
|
})
|
|
);
|
|
items.forEach((item) => {
|
|
if (item.kind === "message") {
|
|
sequence.push({
|
|
kind: "assistant-message",
|
|
message: projectMessage(item.message, turn.id)
|
|
});
|
|
return;
|
|
}
|
|
if (item.kind === "thinking") {
|
|
sequence.push({
|
|
kind: "thinking",
|
|
thinking: projectThinking(item.thinking, turn.id)
|
|
});
|
|
return;
|
|
}
|
|
const sourceEntries =
|
|
item.groupEntries ??
|
|
item.toolCalls.map((call) => ({ kind: "tool-call", call }) as const);
|
|
sourceEntries.forEach((entry) => {
|
|
if (entry.kind === "thinking") {
|
|
sequence.push({
|
|
kind: "thinking",
|
|
thinking: projectThinking(entry.thinking, turn.id)
|
|
});
|
|
return;
|
|
}
|
|
if (isApprovalToolCall(entry.call)) {
|
|
return;
|
|
}
|
|
sequence.push({
|
|
kind: "tool-call",
|
|
call: projectAgentToolCall(entry.call)
|
|
});
|
|
});
|
|
});
|
|
return sortTurnSequenceItems(sequence);
|
|
}
|
|
|
|
type PositionedTurnSequenceEntry = {
|
|
item: AgentTurnSequenceItemVM;
|
|
sourceIndex: number;
|
|
position: {
|
|
seq: number;
|
|
occurredAtUnixMs: number;
|
|
};
|
|
};
|
|
|
|
function sortTurnSequenceItems(
|
|
sequence: readonly AgentTurnSequenceItemVM[]
|
|
): AgentTurnSequenceItemVM[] {
|
|
const positioned: PositionedTurnSequenceEntry[] = sequence.map(
|
|
(item, sourceIndex) => ({
|
|
item,
|
|
sourceIndex,
|
|
position: turnSequencePosition(item)
|
|
})
|
|
);
|
|
const sorted: typeof positioned = [];
|
|
let comparableSegment: typeof positioned = [];
|
|
const flushComparableSegment = () => {
|
|
sorted.push(...comparableSegment.sort(compareTurnSequenceEntries));
|
|
comparableSegment = [];
|
|
};
|
|
|
|
for (const entry of positioned) {
|
|
if (turnSequencePositionRank(entry.position) === 2) {
|
|
// An unpositioned row is a hard boundary. Its relation to either side
|
|
// is unknown, so moving known rows across it would change adjacency.
|
|
flushComparableSegment();
|
|
sorted.push(entry);
|
|
continue;
|
|
}
|
|
comparableSegment.push(entry);
|
|
}
|
|
flushComparableSegment();
|
|
return sorted.map((entry) => entry.item);
|
|
}
|
|
|
|
function compareTurnSequenceEntries(
|
|
left: PositionedTurnSequenceEntry,
|
|
right: PositionedTurnSequenceEntry
|
|
): number {
|
|
const leftRank = turnSequencePositionRank(left.position);
|
|
const rightRank = turnSequencePositionRank(right.position);
|
|
if (leftRank !== rightRank) {
|
|
return leftRank - rightRank;
|
|
}
|
|
if (leftRank === 0 && left.position.seq !== right.position.seq) {
|
|
return left.position.seq - right.position.seq;
|
|
}
|
|
if (
|
|
leftRank === 1 &&
|
|
left.position.occurredAtUnixMs !== right.position.occurredAtUnixMs
|
|
) {
|
|
return left.position.occurredAtUnixMs - right.position.occurredAtUnixMs;
|
|
}
|
|
return left.sourceIndex - right.sourceIndex;
|
|
}
|
|
|
|
function turnSequencePositionRank(position: {
|
|
seq: number;
|
|
occurredAtUnixMs: number;
|
|
}): 0 | 1 | 2 {
|
|
if (position.seq > 0) {
|
|
return 0;
|
|
}
|
|
if (position.occurredAtUnixMs > 0) {
|
|
return 1;
|
|
}
|
|
return 2;
|
|
}
|
|
|
|
function turnSequencePosition(item: AgentTurnSequenceItemVM): {
|
|
seq: number;
|
|
occurredAtUnixMs: number;
|
|
} {
|
|
const sourceItems = turnSequenceSourceItems(item);
|
|
const seq = minimumPositive(
|
|
sourceItems.map((sourceItem) => sourceItem.seq ?? 0)
|
|
);
|
|
const occurredAtUnixMs = minimumPositive(
|
|
sourceItems.map(
|
|
(sourceItem) =>
|
|
sourceItem.occurredAtUnixMs ?? sourceItem.createdAtUnixMs ?? 0
|
|
)
|
|
);
|
|
if (occurredAtUnixMs > 0) {
|
|
return { seq, occurredAtUnixMs };
|
|
}
|
|
switch (item.kind) {
|
|
case "user-message":
|
|
return { seq, occurredAtUnixMs: item.row.occurredAtUnixMs ?? 0 };
|
|
case "assistant-message":
|
|
return { seq, occurredAtUnixMs: item.message.occurredAtUnixMs ?? 0 };
|
|
case "thinking":
|
|
return { seq, occurredAtUnixMs: item.thinking.occurredAtUnixMs ?? 0 };
|
|
case "tool-call":
|
|
return { seq, occurredAtUnixMs: item.call.occurredAtUnixMs ?? 0 };
|
|
}
|
|
}
|
|
|
|
function turnSequenceSourceItems(
|
|
item: AgentTurnSequenceItemVM
|
|
): WorkspaceAgentActivityTimelineItem[] {
|
|
switch (item.kind) {
|
|
case "user-message":
|
|
return item.row.messages.flatMap(
|
|
(message) => message.sourceTimelineItems ?? []
|
|
);
|
|
case "assistant-message":
|
|
return item.message.sourceTimelineItems ?? [];
|
|
case "thinking":
|
|
return item.thinking.sourceTimelineItems ?? [];
|
|
case "tool-call":
|
|
return item.call.sourceTimelineItems ?? [];
|
|
}
|
|
}
|
|
|
|
function minimumPositive(values: readonly number[]): number {
|
|
return values.reduce(
|
|
(minimum, value) =>
|
|
value > 0 && (minimum === 0 || value < minimum) ? value : minimum,
|
|
0
|
|
);
|
|
}
|
|
|
|
function projectMessage(
|
|
message: WorkspaceAgentSessionDetailMessage,
|
|
turnId: string
|
|
): AgentMessageContentVM {
|
|
const projected: AgentMessageContentVM = {
|
|
kind: "message-content",
|
|
id: message.id,
|
|
turnId: message.turnId ?? turnId,
|
|
body: message.body,
|
|
presentationKind: resolveAgentTranscriptPresentationKind(
|
|
message.systemNotice ?? null
|
|
),
|
|
statusKind: message.statusKind ?? null,
|
|
occurredAtUnixMs: message.occurredAtUnixMs ?? null,
|
|
visibleError: message.visibleError ?? null,
|
|
systemNotice: message.systemNotice ?? null
|
|
};
|
|
if (message.sourceTimelineItems) {
|
|
projected.sourceTimelineItems = message.sourceTimelineItems;
|
|
if (
|
|
message.sourceTimelineItems.some(
|
|
(item) => item.payload?.messageKind === "plan"
|
|
)
|
|
) {
|
|
projected.contentKind = "plan";
|
|
}
|
|
const collaborationItem = message.sourceTimelineItems.find(
|
|
isCollaborationTimelineItem
|
|
);
|
|
if (collaborationItem) {
|
|
const collaboration = projectAgentCollaborationVM(collaborationItem);
|
|
if (collaboration) {
|
|
projected.contentKind = "collaboration";
|
|
projected.collaboration = collaboration;
|
|
}
|
|
}
|
|
}
|
|
// Message id carries the stable "plan-issue:<issueID>" identity (it is the
|
|
// daemon messageId via the timeline eventId). A malformed body parses to
|
|
// null and stays a plain assistant markdown message.
|
|
const planIssueLink = parseTuttiPlanIssueLink(message.id, message.body);
|
|
if (planIssueLink) {
|
|
projected.contentKind = "tutti-plan-issue-link";
|
|
projected.planIssueLink = planIssueLink;
|
|
}
|
|
return projected;
|
|
}
|
|
|
|
function projectThinking(
|
|
thinking: WorkspaceAgentSessionDetailThinking,
|
|
turnId: string
|
|
): AgentThinkingContentVM {
|
|
const projected: AgentThinkingContentVM = {
|
|
kind: "thinking-content",
|
|
id: thinking.id,
|
|
turnId: thinking.turnId ?? turnId,
|
|
body: thinking.body,
|
|
statusKind: thinking.statusKind ?? null,
|
|
occurredAtUnixMs: thinking.occurredAtUnixMs ?? null
|
|
};
|
|
if (thinking.sourceTimelineItems) {
|
|
projected.sourceTimelineItems = thinking.sourceTimelineItems;
|
|
}
|
|
return projected;
|
|
}
|