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
384 lines
10 KiB
TypeScript
384 lines
10 KiB
TypeScript
import type { AgentPromptContentBlock } from "../../../shared/contracts/dto";
|
|
import type { AgentConversationVM } from "../../../shared/agentConversation/contracts/agentConversationVM";
|
|
import type { WorkspaceAgentSessionDetailGoalControl } from "../../../shared/workspaceAgentSessionDetailViewModel";
|
|
import type { AgentComposerDraft } from "./agentGuiNodeTypes";
|
|
import {
|
|
agentComposerDraftHasContent,
|
|
agentPromptContentToComposerDraft,
|
|
buildAgentComposerDraft,
|
|
emptyAgentComposerDraft
|
|
} from "./agentComposerDraft";
|
|
|
|
export interface AgentComposerInputHistoryEntry {
|
|
id: string;
|
|
draft: AgentComposerDraft;
|
|
}
|
|
|
|
export interface AgentComposerInputHistoryState {
|
|
entryId: string | null;
|
|
pendingOlderPage: boolean;
|
|
}
|
|
|
|
export interface AgentComposerInputHistoryNavigation {
|
|
draft?: AgentComposerDraft;
|
|
handled: boolean;
|
|
requestOlderPage: boolean;
|
|
state: AgentComposerInputHistoryState;
|
|
}
|
|
|
|
export const EMPTY_AGENT_COMPOSER_INPUT_HISTORY_STATE: AgentComposerInputHistoryState =
|
|
{
|
|
entryId: null,
|
|
pendingOlderPage: false
|
|
};
|
|
|
|
const inputHistoryByConversation = new WeakMap<
|
|
AgentConversationVM,
|
|
AgentComposerInputHistoryEntry[]
|
|
>();
|
|
|
|
interface ProjectedHistoryEntry {
|
|
entry: AgentComposerInputHistoryEntry;
|
|
occurredAtUnixMs: number | null;
|
|
sequence: number;
|
|
}
|
|
|
|
export function projectAgentComposerInputHistory(
|
|
conversation: AgentConversationVM | null
|
|
): AgentComposerInputHistoryEntry[] {
|
|
if (!conversation) {
|
|
return [];
|
|
}
|
|
const cached = inputHistoryByConversation.get(conversation);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
const projectedEntries: ProjectedHistoryEntry[] = [];
|
|
let sequence = 0;
|
|
const append = (
|
|
entry: AgentComposerInputHistoryEntry | null,
|
|
occurredAtUnixMs?: number | null
|
|
): void => {
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
projectedEntries.push({
|
|
entry,
|
|
occurredAtUnixMs: occurredAtUnixMs ?? null,
|
|
sequence: sequence++
|
|
});
|
|
};
|
|
|
|
const entries: AgentComposerInputHistoryEntry[] = [];
|
|
for (const turn of conversation.sourceDetail.turns) {
|
|
for (const message of turn.userMessages) {
|
|
append(projectHistoryEntry(message, turn.id), message.occurredAtUnixMs);
|
|
}
|
|
}
|
|
for (const control of conversation.sourceDetail.goalControls ?? []) {
|
|
append(projectGoalControlHistoryEntry(control), control.occurredAtUnixMs);
|
|
}
|
|
|
|
projectedEntries.sort(compareProjectedHistoryEntries);
|
|
for (const { entry } of projectedEntries) {
|
|
const previous = entries.at(-1);
|
|
if (
|
|
previous &&
|
|
historyEntryDuplicateKey(previous) === historyEntryDuplicateKey(entry)
|
|
) {
|
|
// Keep the newest duplicate id. Prepending an older page then cannot
|
|
// invalidate a cursor that already points at the loaded copy.
|
|
entries[entries.length - 1] = entry;
|
|
} else {
|
|
entries.push(entry);
|
|
}
|
|
}
|
|
inputHistoryByConversation.set(conversation, entries);
|
|
return entries;
|
|
}
|
|
|
|
export function navigateAgentComposerInputHistory(input: {
|
|
currentDraft: AgentComposerDraft;
|
|
direction: "older" | "newer";
|
|
entries: readonly AgentComposerInputHistoryEntry[];
|
|
hasOlderPage: boolean;
|
|
state: AgentComposerInputHistoryState;
|
|
}): AgentComposerInputHistoryNavigation {
|
|
let currentIndex = input.state.entryId
|
|
? input.entries.findIndex((entry) => entry.id === input.state.entryId)
|
|
: -1;
|
|
if (
|
|
input.state.entryId &&
|
|
(currentIndex < 0 ||
|
|
!areAgentComposerHistoryDraftsEqual(
|
|
input.currentDraft,
|
|
input.entries[currentIndex]!.draft
|
|
))
|
|
) {
|
|
if (agentComposerDraftHasContent(input.currentDraft)) {
|
|
return unhandledHistoryNavigation();
|
|
}
|
|
currentIndex = -1;
|
|
}
|
|
|
|
if (currentIndex < 0) {
|
|
if (
|
|
input.direction === "newer" ||
|
|
agentComposerDraftHasContent(input.currentDraft)
|
|
) {
|
|
return unhandledHistoryNavigation();
|
|
}
|
|
const latest = input.entries.at(-1);
|
|
if (latest) {
|
|
return recalledHistoryNavigation(latest);
|
|
}
|
|
if (input.hasOlderPage) {
|
|
return olderPageHistoryNavigation(null);
|
|
}
|
|
return unhandledHistoryNavigation();
|
|
}
|
|
|
|
if (input.direction === "older") {
|
|
const older = input.entries[currentIndex - 1];
|
|
if (older) {
|
|
return recalledHistoryNavigation(older);
|
|
}
|
|
if (input.hasOlderPage) {
|
|
return olderPageHistoryNavigation(input.state.entryId);
|
|
}
|
|
return {
|
|
handled: false,
|
|
requestOlderPage: false,
|
|
state: { entryId: input.state.entryId, pendingOlderPage: false }
|
|
};
|
|
}
|
|
|
|
const newer = input.entries[currentIndex + 1];
|
|
if (newer) {
|
|
return recalledHistoryNavigation(newer);
|
|
}
|
|
return {
|
|
draft: emptyAgentComposerDraft(),
|
|
handled: true,
|
|
requestOlderPage: false,
|
|
state: EMPTY_AGENT_COMPOSER_INPUT_HISTORY_STATE
|
|
};
|
|
}
|
|
|
|
export function resolvePendingAgentComposerInputHistory(input: {
|
|
entries: readonly AgentComposerInputHistoryEntry[];
|
|
state: AgentComposerInputHistoryState;
|
|
}): AgentComposerInputHistoryNavigation | null {
|
|
if (!input.state.pendingOlderPage) {
|
|
return null;
|
|
}
|
|
if (input.state.entryId === null) {
|
|
const latest = input.entries.at(-1);
|
|
return latest
|
|
? recalledHistoryNavigation(latest)
|
|
: {
|
|
handled: false,
|
|
requestOlderPage: false,
|
|
state: EMPTY_AGENT_COMPOSER_INPUT_HISTORY_STATE
|
|
};
|
|
}
|
|
const currentIndex = input.entries.findIndex(
|
|
(entry) => entry.id === input.state.entryId
|
|
);
|
|
const older = currentIndex > 0 ? input.entries[currentIndex - 1] : null;
|
|
return older
|
|
? recalledHistoryNavigation(older)
|
|
: {
|
|
handled: false,
|
|
requestOlderPage: false,
|
|
state: { entryId: input.state.entryId, pendingOlderPage: false }
|
|
};
|
|
}
|
|
|
|
export function areAgentComposerHistoryDraftsEqual(
|
|
left: AgentComposerDraft,
|
|
right: AgentComposerDraft
|
|
): boolean {
|
|
return historyDraftValue(left) === historyDraftValue(right);
|
|
}
|
|
|
|
function projectHistoryEntry(
|
|
message: AgentConversationVM["sourceDetail"]["turns"][number]["userMessages"][number],
|
|
fallbackTurnId: string
|
|
): AgentComposerInputHistoryEntry | null {
|
|
const sourceItem = message.sourceTimelineItems?.find((item) =>
|
|
Array.isArray(item.payload?.content)
|
|
);
|
|
const rawContent = Array.isArray(sourceItem?.payload?.content)
|
|
? sourceItem.payload.content
|
|
: [];
|
|
const content = rawContent.filter((block): block is AgentPromptContentBlock =>
|
|
Boolean(block && typeof block === "object" && !Array.isArray(block))
|
|
);
|
|
const displayPrompt =
|
|
message.sourceTimelineItems
|
|
?.map((item) =>
|
|
typeof item.payload?.displayPrompt === "string"
|
|
? item.payload.displayPrompt.trim()
|
|
: ""
|
|
)
|
|
.find(Boolean) ?? "";
|
|
const visibleDisplayPrompt = isSyntheticImageOnlyDisplayPrompt(
|
|
displayPrompt,
|
|
rawContent
|
|
)
|
|
? ""
|
|
: displayPrompt;
|
|
const draft =
|
|
content.length > 0
|
|
? agentPromptContentToComposerDraft(
|
|
content,
|
|
`history-${message.turnId ?? fallbackTurnId}-${message.id}`
|
|
)
|
|
: buildAgentComposerDraft({
|
|
prompt: visibleDisplayPrompt || message.body
|
|
});
|
|
if (!agentComposerDraftHasContent(draft)) {
|
|
return null;
|
|
}
|
|
const entry = {
|
|
id: `${message.turnId ?? fallbackTurnId}:${message.id}`,
|
|
draft
|
|
};
|
|
historyDuplicateKeyByEntry.set(
|
|
entry,
|
|
JSON.stringify({
|
|
content,
|
|
prompt: visibleDisplayPrompt || (content.length === 0 ? message.body : "")
|
|
})
|
|
);
|
|
return entry;
|
|
}
|
|
|
|
function projectGoalControlHistoryEntry(
|
|
control: WorkspaceAgentSessionDetailGoalControl
|
|
): AgentComposerInputHistoryEntry | null {
|
|
const draft = buildAgentComposerDraft({ prompt: control.body });
|
|
if (!agentComposerDraftHasContent(draft)) {
|
|
return null;
|
|
}
|
|
return {
|
|
id: `goal-control:${control.id}`,
|
|
draft
|
|
};
|
|
}
|
|
|
|
function compareProjectedHistoryEntries(
|
|
left: ProjectedHistoryEntry,
|
|
right: ProjectedHistoryEntry
|
|
): number {
|
|
if (
|
|
left.occurredAtUnixMs !== null &&
|
|
right.occurredAtUnixMs !== null &&
|
|
left.occurredAtUnixMs !== right.occurredAtUnixMs
|
|
) {
|
|
return left.occurredAtUnixMs - right.occurredAtUnixMs;
|
|
}
|
|
return left.sequence - right.sequence;
|
|
}
|
|
|
|
const historyDuplicateKeyByEntry = new WeakMap<
|
|
AgentComposerInputHistoryEntry,
|
|
string
|
|
>();
|
|
|
|
function historyEntryDuplicateKey(
|
|
entry: AgentComposerInputHistoryEntry
|
|
): string {
|
|
const cached = historyDuplicateKeyByEntry.get(entry);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
const key = historyDraftValueWithOptions(entry.draft, true);
|
|
historyDuplicateKeyByEntry.set(entry, key);
|
|
return key;
|
|
}
|
|
|
|
function isSyntheticImageOnlyDisplayPrompt(
|
|
displayPrompt: string,
|
|
content: readonly unknown[]
|
|
): boolean {
|
|
if (displayPrompt !== "[Image]" && displayPrompt !== "[Images]") {
|
|
return false;
|
|
}
|
|
let imageCount = 0;
|
|
for (const raw of content) {
|
|
const block =
|
|
raw && typeof raw === "object" && !Array.isArray(raw)
|
|
? (raw as Record<string, unknown>)
|
|
: null;
|
|
if (!block) {
|
|
continue;
|
|
}
|
|
if (
|
|
block.type === "text" &&
|
|
typeof block.text === "string" &&
|
|
block.text.trim()
|
|
) {
|
|
return false;
|
|
}
|
|
if (block.type === "image") {
|
|
imageCount += 1;
|
|
}
|
|
}
|
|
return displayPrompt === "[Image]" ? imageCount === 1 : imageCount > 1;
|
|
}
|
|
|
|
function recalledHistoryNavigation(
|
|
entry: AgentComposerInputHistoryEntry
|
|
): AgentComposerInputHistoryNavigation {
|
|
return {
|
|
draft: entry.draft,
|
|
handled: true,
|
|
requestOlderPage: false,
|
|
state: { entryId: entry.id, pendingOlderPage: false }
|
|
};
|
|
}
|
|
|
|
function olderPageHistoryNavigation(
|
|
entryId: string | null
|
|
): AgentComposerInputHistoryNavigation {
|
|
return {
|
|
handled: true,
|
|
requestOlderPage: true,
|
|
state: { entryId, pendingOlderPage: true }
|
|
};
|
|
}
|
|
|
|
function unhandledHistoryNavigation(): AgentComposerInputHistoryNavigation {
|
|
return {
|
|
handled: false,
|
|
requestOlderPage: false,
|
|
state: EMPTY_AGENT_COMPOSER_INPUT_HISTORY_STATE
|
|
};
|
|
}
|
|
|
|
function historyDraftValue(draft: AgentComposerDraft): string {
|
|
return historyDraftValueWithOptions(draft, false);
|
|
}
|
|
|
|
function historyDraftValueWithOptions(
|
|
draft: AgentComposerDraft,
|
|
ignoreGeneratedIds: boolean
|
|
): string {
|
|
return JSON.stringify(
|
|
draft.map((block) =>
|
|
block.type === "image"
|
|
? {
|
|
...block,
|
|
...(ignoreGeneratedIds ? { id: "" } : {}),
|
|
// Preview data is presentation-only and may arrive asynchronously.
|
|
previewUrl: ""
|
|
}
|
|
: block.type === "file" && ignoreGeneratedIds
|
|
? { ...block, id: "" }
|
|
: block
|
|
)
|
|
);
|
|
}
|