源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
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

This commit is contained in:
agent-radar
2026-09-01 20:17:27 +08:00
commit 2ed3415fb7
8018 changed files with 1936476 additions and 0 deletions
@@ -0,0 +1,76 @@
import {
createContext,
useContext,
useMemo,
type JSX,
type ReactNode
} from "react";
import type {
AgentGUIObservationGap,
AgentGUIObservationGapSource
} from "../../types";
import { useEngineSelector } from "../engine/useEngineSelector";
const AgentObservationGapSourceContext =
createContext<AgentGUIObservationGapSource | null>(null);
export function AgentObservationGapSourceProvider({
children,
source
}: {
children: ReactNode;
source?: AgentGUIObservationGapSource | null;
}): JSX.Element {
return (
<AgentObservationGapSourceContext.Provider value={source ?? null}>
{children}
</AgentObservationGapSourceContext.Provider>
);
}
export function useAgentObservationGap(
agentSessionId: string | null | undefined,
turnId: string | null | undefined,
explicitSource?: AgentGUIObservationGapSource | null
): AgentGUIObservationGap | null {
const contextSource = useContext(AgentObservationGapSourceContext);
const source = explicitSource === undefined ? contextSource : explicitSource;
const binding = useMemo(
() => ({
getSnapshot: () => {
const normalizedAgentSessionId = agentSessionId?.trim() ?? "";
const normalizedTurnId = turnId?.trim() ?? "";
return normalizedAgentSessionId && normalizedTurnId
? (source?.getObservationGap(
normalizedAgentSessionId,
normalizedTurnId
) ?? null)
: null;
},
subscribe: (listener: () => void) =>
source?.subscribe(listener) ?? (() => undefined)
}),
[agentSessionId, source, turnId]
);
return useEngineSelector(
binding,
identityObservationGap,
observationGapsEqual
);
}
function identityObservationGap(
gap: AgentGUIObservationGap | null
): AgentGUIObservationGap | null {
return gap;
}
function observationGapsEqual(
left: AgentGUIObservationGap | null,
right: AgentGUIObservationGap | null
): boolean {
return (
left?.startedAtUnixMs === right?.startedAtUnixMs &&
left?.presentationState === right?.presentationState
);
}
@@ -0,0 +1,27 @@
import {
resolveWorkspaceLinkAction,
type WorkspaceLinkAction,
type WorkspaceLinkActionSource
} from "../../../contexts/workspace/presentation/renderer/actions/workspaceLinkActions";
import type { AgentConversationVM } from "../contracts/agentConversationVM";
export type { WorkspaceLinkAction, WorkspaceLinkActionSource };
export function resolveAgentConversationLinkAction({
workspaceRoot,
basePath,
href,
source
}: {
workspaceRoot: AgentConversationVM["workspaceRoot"];
basePath: AgentConversationVM["sourceDetail"]["cwd"];
href: string;
source: WorkspaceLinkActionSource;
}): WorkspaceLinkAction | null {
return resolveWorkspaceLinkAction({
href,
workspaceRoot: workspaceRoot,
basePath,
source
});
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { resolveAgentConversationNavigationAction } from "./agentConversationNavigationActions";
describe("resolveAgentConversationNavigationAction", () => {
it("keeps the portable action set to URLs and Agent Sessions", () => {
expect(
resolveAgentConversationNavigationAction({
href: "mention://agent-session/session-1?workspaceId=workspace-1",
source: "agent-markdown"
})
).toEqual({
agentSessionId: "session-1",
source: "agent-markdown",
type: "open-agent-session",
workspaceId: "workspace-1"
});
expect(
resolveAgentConversationNavigationAction({
href: "https://tutti.dev/docs",
source: "agent-markdown"
})
).toEqual({
source: "agent-markdown",
type: "open-url",
url: "https://tutti.dev/docs"
});
expect(
resolveAgentConversationNavigationAction({
href: "./README.md",
source: "agent-markdown"
})
).toBeNull();
});
});
@@ -0,0 +1,32 @@
import {
resolveAgentSessionMentionLinkAction,
resolveWorkspaceUrlLinkAction,
type OpenAgentSessionLinkAction,
type OpenWorkspaceUrlLinkAction,
type WorkspaceLinkActionSource
} from "../../../actions/portableWorkspaceNavigationActions";
export type AgentConversationNavigationAction =
| OpenAgentSessionLinkAction
| OpenWorkspaceUrlLinkAction;
/**
* Cross-renderer baseline for conversation links.
*
* Workspace files, local assets, apps, issues, and custom mentions remain host
* capabilities. Alternate renderers can share URL and Session navigation
* without importing those host policies.
*/
export function resolveAgentConversationNavigationAction({
href,
source
}: {
href: string;
source: WorkspaceLinkActionSource;
}): AgentConversationNavigationAction | null {
const mention = resolveAgentSessionMentionLinkAction({ href, source });
if (mention) {
return mention;
}
return resolveWorkspaceUrlLinkAction({ url: href, source });
}
@@ -0,0 +1,7 @@
export type AgentApprovalPurpose = "edit-files";
export function normalizeAgentApprovalPurpose(
value: unknown
): AgentApprovalPurpose | null {
return value === "edit-files" ? value : null;
}
@@ -0,0 +1,28 @@
/**
* Creates an opaque client identity suitable for cross-host Agent commands.
* Business provenance belongs in typed command or pending-intent fields, never
* in this value.
*/
export function createAgentClientId(): string {
const crypto = globalThis.crypto;
if (typeof crypto?.randomUUID === "function") {
return crypto.randomUUID();
}
if (typeof crypto?.getRandomValues !== "function") {
throw new Error("Secure UUID generation is unavailable");
}
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Array.from(bytes, (value) =>
value.toString(16).padStart(2, "0")
).join("");
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20)
].join("-");
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { createAgentConversationFollowEndController } from "./agentConversationFollowEndController";
describe("createAgentConversationFollowEndController", () => {
it("starts by following the conversation end", () => {
const controller = createAgentConversationFollowEndController();
expect(controller.getSnapshot()).toBe("following");
});
it("keeps scroll-away intent until an explicit follow event", () => {
const controller = createAgentConversationFollowEndController();
controller.dispatch("user-scrolled-away");
expect(controller.getSnapshot()).toBe("detached");
controller.dispatch("user-reached-end");
expect(controller.getSnapshot()).toBe("following");
});
it.each([
"conversation-changed",
"prompt-submitted",
"scroll-to-end-requested"
] as const)("reattaches for %s", (event) => {
const controller = createAgentConversationFollowEndController();
controller.dispatch("user-scrolled-away");
controller.dispatch(event);
expect(controller.getSnapshot()).toBe("following");
});
it("returns the resulting mode to UI adapters", () => {
const controller = createAgentConversationFollowEndController();
expect(controller.dispatch("user-scrolled-away")).toBe("detached");
expect(controller.dispatch("user-reached-end")).toBe("following");
});
});
@@ -0,0 +1,35 @@
export type AgentConversationFollowEndMode = "following" | "detached";
export type AgentConversationFollowEndEvent =
| "conversation-changed"
| "prompt-submitted"
| "scroll-to-end-requested"
| "user-reached-end"
| "user-scrolled-away";
export interface AgentConversationFollowEndController {
dispatch(
event: AgentConversationFollowEndEvent
): AgentConversationFollowEndMode;
getSnapshot(): AgentConversationFollowEndMode;
}
function nextFollowEndMode(
event: AgentConversationFollowEndEvent
): AgentConversationFollowEndMode {
return event === "user-scrolled-away" ? "detached" : "following";
}
export function createAgentConversationFollowEndController(): AgentConversationFollowEndController {
let mode: AgentConversationFollowEndMode = "following";
return {
dispatch(event) {
mode = nextFollowEndMode(event);
return mode;
},
getSnapshot() {
return mode;
}
};
}
@@ -0,0 +1,191 @@
import { describe, expect, it } from "vitest";
import {
createAgentSessionEngine,
type EngineCommand
} from "@tutti-os/agent-activity-core";
import { createTestEngineCommandPort } from "../testing/createTestAgentSessionEngine";
import { dispatchAgentPlanPromptAction } from "./agentPlanPromptDispatch";
const UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
describe("dispatchAgentPlanPromptAction", () => {
it.each([
["implement", "plan/submitDecision"],
["feedback", "queue/sendPrompt"],
["skip", null]
] as const)(
"dispatches %s against a settled completed plan turn",
async (action, commandType) => {
const executed: EngineCommand[] = [];
const engine = createAgentSessionEngine({
clock: { nowUnixMs: () => 10 },
commandPort: createTestEngineCommandPort({
async executePlanDecision(command) {
executed.push(command);
return {
operation: {
agentSessionId: command.agentSessionId,
idempotencyKey: command.idempotencyKey,
operationId: `operation:${command.commandId}`,
requestId: command.requestId,
status: "prepared",
turnId: command.turnId,
workspaceId: command.workspaceId
}
};
},
async execute(command) {
executed.push(command);
}
}),
identity: { origin: "test", workspaceId: "workspace-1" },
scheduler: { schedule: () => ({ cancel() {} }) }
});
engine.dispatch({
type: "session/snapshotReceived",
sessions: [
{
...{
activeTurnId: null,
latestTurnInteractions: [],
pendingInteractions: []
},
workspaceId: "workspace-1",
agentSessionId: "session-1",
provider: "codex",
cwd: "/workspace",
title: "Plan",
activeTurnId: null,
latestTurn: {
agentSessionId: "session-1",
turnId: "turn-1",
origin: "user_prompt",
phase: "settled",
outcome: "completed",
startedAtUnixMs: 1,
updatedAtUnixMs: 2,
settledAtUnixMs: 2
}
}
]
});
expect(
dispatchAgentPlanPromptAction({
action,
agentSessionId: "session-1",
engine,
feedbackText: action === "feedback" ? "Revise it" : undefined,
nowUnixMs: () => 20,
requestId: "turn-1",
workspaceId: "workspace-1"
})
).toBe(true);
await Promise.resolve();
expect(executed[0]?.type ?? null).toBe(commandType);
if (executed[0]?.type === "queue/sendPrompt") {
expect(executed[0].clientSubmitId).toMatch(UUID_V4_PATTERN);
}
}
);
it("rejects a request id that is not the latest settled completed turn", () => {
const engine = createAgentSessionEngine({
clock: { nowUnixMs: () => 1 },
commandPort: createTestEngineCommandPort({
execute: async () => undefined
}),
identity: { origin: "test", workspaceId: "workspace-1" },
scheduler: { schedule: () => ({ cancel() {} }) }
});
expect(
dispatchAgentPlanPromptAction({
action: "implement",
agentSessionId: "session-1",
engine,
requestId: "turn-1",
workspaceId: "workspace-1"
})
).toBe(false);
});
it("adds Tutti audit provenance to Plan feedback without changing Plan state", async () => {
const executed: EngineCommand[] = [];
const engine = createAgentSessionEngine({
clock: { nowUnixMs: () => 10 },
commandPort: createTestEngineCommandPort({
async execute(command) {
executed.push(command);
}
}),
identity: { origin: "test", workspaceId: "workspace-1" },
scheduler: { schedule: () => ({ cancel() {} }) }
});
engine.dispatch({
sessions: [
{
activeTurnId: null,
agentSessionId: "session-1",
cwd: "/workspace",
latestTurn: {
agentSessionId: "session-1",
outcome: "completed",
origin: "user_prompt",
phase: "settled",
settledAtUnixMs: 2,
startedAtUnixMs: 1,
turnId: "turn-1",
updatedAtUnixMs: 2
},
latestTurnInteractions: [],
pendingInteractions: [],
provider: "codex",
settings: { planMode: true },
title: "Plan with Tutti",
tuttiModeActivation: {
agentSessionId: "session-1",
createdAtUnixMs: 1,
currentRevision: {
activationId: "tutti-1",
createdAtUnixMs: 1,
effect: 50,
orchestrationIntensity: 50,
speed: 50,
revision: 1,
source: "slash_command",
status: "active"
},
id: "tutti-1",
status: "active",
updatedAtUnixMs: 1,
workspaceId: "workspace-1"
},
updatedAtUnixMs: 2,
workspaceId: "workspace-1"
}
],
type: "session/snapshotReceived"
});
expect(
dispatchAgentPlanPromptAction({
action: "feedback",
agentSessionId: "session-1",
engine,
feedbackText: "Revise it",
nowUnixMs: () => 20,
requestId: "turn-1",
workspaceId: "workspace-1"
})
).toBe(true);
await Promise.resolve();
expect(executed[0]?.type).toBe("queue/sendPrompt");
expect(
executed[0]?.type === "queue/sendPrompt"
? executed[0].capabilityRefs
: null
).toEqual([{ capability: "tutti", source: "slash_command" }]);
});
});
@@ -0,0 +1,119 @@
import {
selectPlanDecisionForTurn,
selectTuttiModeActivationPresentation,
selectWorkspaceAgentConsumerSession,
type AgentSessionEngine,
type AgentSessionEngineState
} from "@tutti-os/agent-activity-core";
import {
PLAN_IMPLEMENTATION_ACTION_FEEDBACK,
PLAN_IMPLEMENTATION_ACTION_IMPLEMENT,
PLAN_IMPLEMENTATION_ACTION_SKIP
} from "./planImplementationPresentation";
import { createAgentClientId } from "./agentClientIdentity";
export type AgentPlanPromptAction =
| typeof PLAN_IMPLEMENTATION_ACTION_IMPLEMENT
| typeof PLAN_IMPLEMENTATION_ACTION_FEEDBACK
| typeof PLAN_IMPLEMENTATION_ACTION_SKIP;
export function selectAgentPlanPromptTurn(
state: AgentSessionEngineState,
agentSessionId: string,
requestId: string
) {
const latestTurn = selectWorkspaceAgentConsumerSession(
state,
agentSessionId
)?.latestTurn;
return latestTurn?.turnId === requestId &&
latestTurn.phase === "settled" &&
latestTurn.outcome === "completed"
? latestTurn
: null;
}
export function dispatchAgentPlanPromptAction(input: {
action: AgentPlanPromptAction;
agentSessionId: string;
engine: AgentSessionEngine;
feedbackText?: string;
nowUnixMs?: () => number;
requestId: string;
workspaceId: string;
}): boolean {
const turn = selectAgentPlanPromptTurn(
input.engine.getSnapshot(),
input.agentSessionId,
input.requestId
);
if (!turn) return false;
const scopeKey = [
"plan-implementation",
input.workspaceId,
input.agentSessionId,
turn.turnId
].join(":");
if (input.action === PLAN_IMPLEMENTATION_ACTION_IMPLEMENT) {
const existing = selectPlanDecisionForTurn(
input.engine.getSnapshot(),
input.agentSessionId,
turn.turnId
);
input.engine.dispatch({
type: "plan/decisionRequested",
action: "implement",
agentSessionId: input.agentSessionId,
commandId: `plan-decision:${scopeKey}`,
idempotencyKey: scopeKey,
promptKind: "plan-implementation",
requestId: input.requestId,
turnId: turn.turnId,
workspaceId: input.workspaceId,
...(existing?.status === "failed" || existing?.status === "unknown"
? { retry: true }
: {}),
timeoutMs: 30_000
});
return true;
}
if (input.action === PLAN_IMPLEMENTATION_ACTION_SKIP) {
input.engine.dispatch({
type: "plan/skipped",
agentSessionId: input.agentSessionId,
requestId: input.requestId,
turnId: turn.turnId,
workspaceId: input.workspaceId
});
return true;
}
const text = input.feedbackText?.trim() ?? "";
if (!text) return false;
const requestedAtUnixMs = (input.nowUnixMs ?? Date.now)();
const tuttiModeActive = selectTuttiModeActivationPresentation(
input.engine.getSnapshot(),
input.agentSessionId,
""
).active;
input.engine.dispatch({
type: "plan/feedbackRequested",
agentSessionId: input.agentSessionId,
...(tuttiModeActive
? {
capabilityRefs: [
{ capability: "tutti" as const, source: "slash_command" as const }
]
}
: {}),
clientSubmitId: createAgentClientId(),
content: [{ type: "text", text }],
displayPrompt: text,
expiresAtUnixMs: requestedAtUnixMs + 120_000,
requestedAtUnixMs,
requestId: input.requestId,
runtimeContent: [{ type: "text", text }],
turnId: turn.turnId,
workspaceId: input.workspaceId
});
return true;
}
@@ -0,0 +1,157 @@
import { beforeEach, describe, expect, it } from "vitest";
import { setAgentGuiI18nTestLocale } from "../../i18n/testUtils";
import {
approvalOptionDisplayLabel,
approvalOptionVisualPresentation
} from "./approvalOptionPresentation";
describe("approvalOptionDisplayLabel", () => {
beforeEach(() => {
setAgentGuiI18nTestLocale("zh-CN");
});
it("localizes known plan-mode approval option ids", () => {
expect(
approvalOptionDisplayLabel({
id: "auto",
kind: "allow_always",
label: 'Yes, and use "auto" mode'
})
).toBe("允许,并使用自动模式");
expect(
approvalOptionDisplayLabel({
id: "default",
kind: "allow_once",
label: "Yes, and manually approve edits"
})
).toBe("允许,并手动确认编辑");
});
it("localizes Codex ACP permission option labels", () => {
expect(
approvalOptionDisplayLabel({
id: "approved",
kind: "allow_once",
label: "Approve"
})
).toBe("允许执行");
expect(
approvalOptionDisplayLabel({
id: "approved-for-session",
kind: "allow_always",
label: "Approve for session"
})
).toBe("本次会话允许");
expect(
approvalOptionDisplayLabel({
id: "approved-always",
kind: "allow_always",
label: "Allow and don't ask again"
})
).toBe("允许,并且不再询问");
expect(
approvalOptionDisplayLabel({
id: "cancel",
kind: "reject_once",
label: "Cancel"
})
).toBe("取消");
expect(
approvalOptionDisplayLabel(
{
id: "cancel",
kind: "reject_once",
label: "Cancel"
},
{ feedback: true }
)
).toBe("拒绝,然后发送新的指令");
expect(
approvalOptionDisplayLabel({
id: "abort",
kind: "reject_always",
label: "Deny and stop the turn"
})
).toBe("拒绝并停止当前回复");
setAgentGuiI18nTestLocale("en");
expect(
approvalOptionDisplayLabel({
id: "abort",
kind: "reject_always",
label: "Deny and stop the turn"
})
).toBe("Deny and stop the current response");
});
it("does not infer stop semantics from an opaque abort option id", () => {
expect(
approvalOptionDisplayLabel(
{
id: "abort",
kind: "reject_once",
label: "No, and tell Codex what to do differently"
},
{ feedback: true }
)
).toBe("拒绝,然后发送新的指令");
expect(
approvalOptionDisplayLabel(
{
id: "abort",
kind: "reject_always",
label: "Deny and stop the turn"
},
{ feedback: true }
)
).toBe("拒绝,然后发送新的指令");
});
it("falls back to provider labels for ambiguous option ids", () => {
expect(
approvalOptionDisplayLabel({
id: "auto",
kind: "allow_always",
label: "Auto approve this provider-specific scope"
})
).toBe("Auto approve this provider-specific scope");
expect(
approvalOptionDisplayLabel({
id: "default",
kind: "allow_once",
label: "Use provider default"
})
).toBe("Use provider default");
});
it("localizes provider labels with known allow scopes", () => {
expect(
approvalOptionDisplayLabel({
id: "approved-execpolicy-amendment",
kind: "allow_always",
label: "Yes, and don't ask again for commands that start with `curl`"
})
).toBe("允许,并且不再询问以 `curl` 开头的命令");
expect(
approvalOptionDisplayLabel({
id: "allow_always",
kind: "allow_always",
label: "Always Allow Bash(chmod +x ./bootstrap.sh)"
})
).toBe("始终允许 Bash(chmod +x ./bootstrap.sh)");
});
it("splits command-prefix approval options for visual display", () => {
expect(
approvalOptionVisualPresentation({
id: "approved-execpolicy-amendment",
kind: "allow_always",
label:
"Yes, and don't ask again for commands that start with `pnpm --dir apps/tsh-desktop view`"
})
).toEqual({
label: "允许,并且不再询问以下列内容开头的命令",
commandPrefix: "pnpm --dir apps/tsh-desktop view"
});
});
});
@@ -0,0 +1,219 @@
import { translate } from "../../i18n/index";
export interface ApprovalOptionVisualPresentation {
label: string;
commandPrefix?: string;
}
export function approvalOptionDisplayLabel(
option: {
id: string;
kind: string;
label: string;
},
intent: { feedback?: boolean } = {}
): string {
const idToken = normalizeApprovalOptionToken(option.id);
const kindToken = normalizeApprovalOptionToken(option.kind);
const label = option.label.trim();
const specificTranslationKey = approvalOptionSpecificTranslationKey(
idToken,
kindToken,
label,
intent
);
if (specificTranslationKey) {
return translate(specificTranslationKey);
}
const providerLabelTranslation =
approvalOptionProviderLabelTranslation(label);
if (providerLabelTranslation) {
return translate(
providerLabelTranslation.key,
providerLabelTranslation.params
);
}
if (
(idToken === "allowonce" || kindToken === "allowonce") &&
isGenericAllowOnceLabel(label)
) {
return translate("agentHost.agentGui.approvalOptions.allowOnce");
}
if (isGenericAllowForSessionLabel(label)) {
return translate("agentHost.agentGui.approvalOptions.allowForSession");
}
if (
(idToken === "allowalways" ||
idToken === "allowall" ||
kindToken === "allowalways") &&
isGenericApprovalLabel(label)
) {
return translate("agentHost.agentGui.approvalOptions.allowAlways");
}
if (idToken === "rejectalways" || kindToken === "rejectalways") {
return intent.feedback
? translate("agentHost.agentGui.approvalOptions.rejectWithFollowUp")
: translate("agentHost.agentGui.approvalOptions.rejectAlways");
}
if (
idToken === "rejectonce" ||
idToken === "reject" ||
idToken === "deny" ||
kindToken === "rejectonce" ||
kindToken === "reject" ||
kindToken === "deny"
) {
return intent.feedback
? translate("agentHost.agentGui.approvalOptions.rejectWithFollowUp")
: translate("agentHost.agentGui.approvalOptions.rejectOnce");
}
return label || option.id;
}
export function approvalOptionVisualPresentation(
option: {
id: string;
kind: string;
label: string;
},
intent: { feedback?: boolean } = {}
): ApprovalOptionVisualPresentation {
const commandPrefix = approvalOptionCommandPrefix(option.label);
if (commandPrefix) {
return {
label: translate(
"agentHost.agentGui.approvalOptions.allowAlwaysForCommandPrefixLead"
),
commandPrefix
};
}
return {
label: approvalOptionDisplayLabel(option, intent)
};
}
export function normalizeApprovalOptionToken(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "");
}
function approvalOptionSpecificTranslationKey(
token: string,
kindToken: string,
label: string,
intent: { feedback?: boolean } = {}
): string | null {
const labelToken = normalizeApprovalOptionToken(label);
switch (token) {
case "abort":
return !intent.feedback &&
kindToken === "rejectalways" &&
labelToken === "denyandstoptheturn"
? "agentHost.agentGui.approvalOptions.rejectAndStopTurn"
: null;
case "approved":
return isGenericAllowOnceLabel(label)
? "agentHost.agentGui.approvalOptions.allowOnce"
: null;
case "approvedforsession":
return isGenericAllowForSessionLabel(label)
? "agentHost.agentGui.approvalOptions.allowForSession"
: null;
case "approvedalways":
return isGenericApprovalLabel(label)
? "agentHost.agentGui.approvalOptions.allowAlways"
: null;
case "bypasspermissions":
return "agentHost.agentGui.approvalOptions.bypassPermissions";
case "auto":
return labelToken === "yesanduseautomode"
? "agentHost.agentGui.approvalOptions.autoMode"
: null;
case "acceptedits":
return "agentHost.agentGui.approvalOptions.acceptEdits";
case "default":
return labelToken === "yesandmanuallyapproveedits"
? "agentHost.agentGui.approvalOptions.manualApproval"
: null;
case "cancel":
return !intent.feedback && labelToken === "cancel"
? "common.cancel"
: null;
default:
return null;
}
}
function approvalOptionProviderLabelTranslation(
label: string
): { key: string; params: Record<string, string> } | null {
const commandPrefix = approvalOptionCommandPrefix(label);
if (commandPrefix) {
return {
key: "agentHost.agentGui.approvalOptions.allowAlwaysForCommandPrefix",
params: { command: commandPrefix }
};
}
const allowScopeMatch = label.match(/^Yes,\s*and don't ask again for (.+)$/i);
if (allowScopeMatch?.[1]) {
return {
key: "agentHost.agentGui.approvalOptions.allowAlwaysForScope",
params: { scope: allowScopeMatch[1] }
};
}
const alwaysAllowMatch = label.match(/^Always Allow\s+(.+)$/i);
if (alwaysAllowMatch?.[1]) {
return {
key: "agentHost.agentGui.approvalOptions.alwaysAllowScope",
params: { scope: alwaysAllowMatch[1] }
};
}
return null;
}
function approvalOptionCommandPrefix(label: string): string | null {
const commandPrefixMatch = label.match(
/^Yes,\s*and don't ask again for commands that start with `([^`]+)`$/i
);
return commandPrefixMatch?.[1]?.trim() || null;
}
function isGenericApprovalLabel(label: string): boolean {
const token = normalizeApprovalOptionToken(label);
return (
token === "" ||
token === "allowalways" ||
token === "allowall" ||
token === "alwaysallow" ||
token === "allowanddontaskagain" ||
token === "yesanddontaskagain"
);
}
function isGenericAllowOnceLabel(label: string): boolean {
const token = normalizeApprovalOptionToken(label);
return (
token === "" ||
token === "allow" ||
token === "allowonce" ||
token === "approve" ||
token === "approveonce" ||
token === "yes" ||
token === "yesproceed" ||
token === "yesandproceed"
);
}
function isGenericAllowForSessionLabel(label: string): boolean {
const token = normalizeApprovalOptionToken(label);
return (
token === "allowforthissession" ||
token === "allowforsession" ||
token === "approveforsession"
);
}
@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import { normalizeAskUserQuestions } from "./askUserQuestions";
describe("normalizeAskUserQuestions", () => {
it("maps header / question / options and defaults multiSelect to false", () => {
expect(
normalizeAskUserQuestions([
{
id: "plan-kind",
header: "Plan topic",
question: "Which kind of plan?",
options: [
{
id: "health-check",
label: "Health check",
description: "Audit the repo"
},
{ id: "feature-plan", label: "Feature plan" }
]
}
])
).toEqual([
{
id: "plan-kind",
header: "Plan topic",
question: "Which kind of plan?",
options: [
{
id: "health-check",
label: "Health check",
description: "Audit the repo"
},
{ id: "feature-plan", label: "Feature plan", description: "" }
],
multiSelect: false
}
]);
});
it("generates deterministic contract IDs for missing provider identities", () => {
const rawQuestions = [
{
header: "Choose",
question: "Which path?",
options: [{ label: "Keep", description: "Reuse the renderer" }]
}
];
const first = normalizeAskUserQuestions(rawQuestions);
const second = normalizeAskUserQuestions(rawQuestions);
expect(second).toEqual(first);
expect(first).toEqual([
{
id: expect.stringMatching(/^contract-question-/u),
header: "Choose",
question: "Which path?",
options: [
{
id: expect.stringMatching(/^contract-option-/u),
label: "Keep",
description: "Reuse the renderer"
}
],
multiSelect: false
}
]);
});
it("drops malformed entries and duplicate contract identities", () => {
expect(
normalizeAskUserQuestions([
null,
{ id: "question-1", options: [{ description: "no label" }] },
{
id: "question-1",
options: [{ id: "keep", label: "Keep" }]
},
{
id: "question-1",
options: [{ id: "other", label: "Duplicate question" }]
}
])
).toEqual([
{
id: "question-1",
header: "Question 2",
question: "Question 2",
options: [],
multiSelect: false
}
]);
});
it("falls back to the header for the question text and carries multiSelect", () => {
expect(
normalizeAskUserQuestions([
{ id: "q", header: "Pick some", multiSelect: true }
])
).toEqual([
{
id: "q",
header: "Pick some",
question: "Pick some",
options: [],
multiSelect: true
}
]);
});
it("preserves an option-only answer surface", () => {
expect(
normalizeAskUserQuestions([
{
id: "question-1",
question: "Pick one",
options: [{ id: "a", label: "A" }],
allowFreeText: false
}
])
).toEqual([
{
id: "question-1",
header: "Question 1",
question: "Pick one",
options: [{ id: "a", label: "A", description: "" }],
multiSelect: false,
allowFreeText: false
}
]);
});
it("ignores non-array input and non-object entries", () => {
expect(normalizeAskUserQuestions(null)).toEqual([]);
expect(normalizeAskUserQuestions("nope")).toEqual([]);
expect(normalizeAskUserQuestions([null, 42, "x"])).toEqual([]);
});
});
@@ -0,0 +1,111 @@
import type { AgentAskUserQuestionVM } from "./contracts/agentAskUserQuestionItemVM";
/**
* Single source of truth for turning a raw AskUserQuestion tool input's
* `questions` array into the view-model shape. Both the in-conversation tool
* projection and the message-center derivation call this, so the two surfaces
* can never drift on which fields they read or how they default them (the
* exact class of bug that left the message-center card without its options).
*
* Input shape (codex / ACP): each entry may carry `id`, `header`, `question`,
* `multiSelect`, `allowFreeText`, and `options: [{ id, label, description }]`.
* Answers are layered on by the caller (the live projection knows them; a
* pending prompt has none), so this returns the answer-less base.
*/
export function normalizeAskUserQuestions(
rawQuestions: unknown,
options: { missingText?: string } = {}
): AgentAskUserQuestionVM[] {
const missingText = options.missingText ?? null;
const seenQuestionIds = new Set<string>();
return arrayValue(rawQuestions).flatMap((value, index) => {
const question = objectValue(value);
if (!question) {
return [];
}
const questionId =
stringValue(question.id) ?? askUserContractId("question", question);
if (seenQuestionIds.has(questionId)) {
return [];
}
seenQuestionIds.add(questionId);
const seenOptionIds = new Set<string>();
return [
{
id: questionId,
header:
stringValue(question.header) ??
missingText ??
`Question ${index + 1}`,
question:
stringValue(question.question) ??
stringValue(question.header) ??
missingText ??
`Question ${index + 1}`,
options: arrayValue(question.options).flatMap((optionValue) => {
const option = objectValue(optionValue);
const label = stringValue(option?.label);
if (!label) {
return [];
}
const description = stringValue(option?.description) ?? "";
const optionId =
stringValue(option?.id) ??
askUserContractId("option", {
description,
label,
questionId
});
if (seenOptionIds.has(optionId)) {
return [];
}
seenOptionIds.add(optionId);
return [
{
id: optionId,
label,
description
}
];
}),
multiSelect: Boolean(question.multiSelect),
...(question.allowFreeText === false ||
question.allow_free_text === false
? { allowFreeText: false }
: {})
}
];
});
}
/**
* Provider payloads may omit UI-facing question or option IDs. Create an
* opaque, deterministic contract identity at the normalization boundary so
* renderers and automation never fall back to array position or inspect
* rendered copy.
*/
function askUserContractId(
scope: "question" | "option",
value: unknown
): string {
let hash = 0x811c9dc5;
for (const character of JSON.stringify(value)) {
hash ^= character.codePointAt(0) ?? 0;
hash = Math.imul(hash, 0x01000193);
}
return `contract-${scope}-${(hash >>> 0).toString(36)}`;
}
function stringValue(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function objectValue(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function arrayValue(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
@@ -0,0 +1,351 @@
import { useState, type JSX } from "react";
import { canonicalInteractionKey } from "@tutti-os/agent-activity-core";
import { Button } from "@tutti-os/ui-system";
import { MessageSquareMoreIcon } from "../../../app/renderer/components/icons/MessageSquareMoreIcon";
import styles from "../../../agent-gui/agentGuiNode/AgentGUIConversation.styles";
import type { AgentConversationPromptVM } from "../contracts/agentConversationVM";
import { buildAskUserAnswerPayload } from "../interactiveAnswerPayload";
import type {
AgentInteractivePromptConversationReturn,
AgentInteractivePromptSurfaceProps,
AgentInteractivePromptVariant
} from "./AgentInteractivePromptSurface";
import {
interactiveOptionLabel,
interactivePromptCardClassName,
interactivePromptClassName,
stripPromptTitlePunctuation
} from "./interactivePromptPresentation";
import { useAskUserAnswerFlow } from "./useAskUserAnswerFlow";
type AskUserPrompt = Extract<AgentConversationPromptVM, { kind: "ask-user" }>;
type SharedAskUserSurfaceProps = Pick<
AgentInteractivePromptSurfaceProps,
"edgeGlow" | "isInteractionDisabled" | "isSubmitting" | "labels" | "onSubmit"
> & {
embedded?: boolean;
prompt: AskUserPrompt;
};
export function AgentAskUserPromptSurface({
prompt,
variant,
conversationReturn,
...props
}: SharedAskUserSurfaceProps & {
variant: AgentInteractivePromptVariant;
conversationReturn?: AgentInteractivePromptConversationReturn;
}): JSX.Element {
"use memo";
const question = prompt.questions[0] ?? null;
const useCompactQuickAnswer =
variant === "compact" &&
prompt.questions.length === 1 &&
question !== null &&
!question.multiSelect &&
question.options.length > 0;
if (useCompactQuickAnswer) {
return (
<CompactQuickAnswerSurface
{...props}
prompt={prompt}
question={question}
/>
);
}
return (
<AskUserAnswerFlowSurface
key={canonicalInteractionKey(
prompt.agentSessionId ?? "",
prompt.turnId ?? "",
prompt.requestId
)}
{...props}
prompt={prompt}
conversationReturn={variant === "full" ? conversationReturn : undefined}
/>
);
}
function CompactQuickAnswerSurface({
prompt,
question,
embedded = false,
edgeGlow = false,
isSubmitting,
isInteractionDisabled = false,
onSubmit
}: Omit<SharedAskUserSurfaceProps, "labels"> & {
question: AskUserPrompt["questions"][number];
}): JSX.Element {
return (
<section
className={interactivePromptClassName(embedded)}
data-agent-interaction-id={prompt.requestId}
data-agent-interaction-kind="ask-user"
data-agent-question-id={question.id}
data-testid={`agent-question-${prompt.requestId}-${question.id}`}
>
<div className={interactivePromptCardClassName(edgeGlow)}>
<div className={styles.interactivePromptHeader}>
<span className={styles.interactivePromptLead}>
{stripPromptTitlePunctuation(question.header)}
</span>
</div>
<div className={styles.interactivePromptQuestion}>
{question.question}
</div>
<div className={styles.interactivePromptOptions}>
{question.options.map((option) => (
<button
key={option.label}
type="button"
className={styles.interactiveOptionButton}
aria-label={interactiveOptionLabel(
option.label,
option.description
)}
data-agent-question-option-id={option.id}
data-testid={
option.id
? `agent-question-${prompt.requestId}-${question.id}-option-${option.id}`
: undefined
}
disabled={isSubmitting || isInteractionDisabled}
onClick={() =>
onSubmit({
requestId: prompt.requestId,
action: "submit",
payload: {
...buildAskUserAnswerPayload({
[question.id]: option.label
})
}
})
}
>
<span className={styles.interactiveOptionContent}>
<span className={styles.interactiveOptionTitle}>
{option.label}
</span>
{option.description ? (
<span className={styles.interactiveOptionDescription}>
{option.description}
</span>
) : null}
</span>
</button>
))}
</div>
</div>
</section>
);
}
function AskUserAnswerFlowSurface({
prompt,
conversationReturn,
embedded = false,
edgeGlow = false,
isSubmitting,
isInteractionDisabled = false,
onSubmit,
labels
}: SharedAskUserSurfaceProps & {
conversationReturn?: AgentInteractivePromptConversationReturn;
}): JSX.Element {
"use memo";
const [collapsed, setCollapsed] = useState(false);
const flow = useAskUserAnswerFlow({
isSubmitting: isSubmitting || isInteractionDisabled,
questions: prompt.questions
});
const question = flow.currentQuestion;
if (!question) {
return (
<section className={interactivePromptClassName(embedded)}>
<div className={interactivePromptCardClassName(edgeGlow)}>
<div
className={`${styles.interactivePromptLead} inline-flex items-center gap-1.5`}
>
<MessageSquareMoreIcon
size={15}
active
aria-hidden="true"
className="shrink-0"
/>
{stripPromptTitlePunctuation(labels.waitingForAnswer)}
</div>
</div>
</section>
);
}
if (collapsed && conversationReturn) {
return (
<section
className={interactivePromptClassName(embedded)}
data-agent-interaction-id={prompt.requestId}
data-agent-interaction-kind="ask-user"
data-testid={`agent-question-${prompt.requestId}-collapsed`}
>
<div
className={`${interactivePromptCardClassName(edgeGlow)} ${styles.interactivePromptCollapsed}`}
>
<span className={styles.interactivePromptLead} role="status">
{stripPromptTitlePunctuation(labels.waitingForAnswer)}
</span>
<Button
type="button"
variant="secondary"
size="sm"
disabled={isSubmitting}
onClick={() => setCollapsed(false)}
>
{conversationReturn.continueAnswering}
</Button>
</div>
</section>
);
}
return (
<section
className={interactivePromptClassName(embedded)}
data-agent-interaction-id={prompt.requestId}
data-agent-interaction-kind="ask-user"
data-agent-question-id={question.id}
data-testid={`agent-question-${prompt.requestId}-${question.id}`}
>
<div className={interactivePromptCardClassName(edgeGlow)}>
<div className={styles.interactivePromptHeader}>
<span className={styles.interactivePromptLead}>
{stripPromptTitlePunctuation(question.header)}
</span>
<span className={styles.interactivePromptMeta}>
{flow.currentIndex + 1}/{prompt.questions.length}
</span>
</div>
<div className={styles.interactivePromptQuestion}>
{question.question}
</div>
{question.options.length > 0 ? (
<div className={styles.interactivePromptOptions}>
{question.options.map((option) => {
const active = flow.selectedOptions.includes(option.label);
return (
<button
key={option.label}
type="button"
className={styles.interactiveOptionButton}
data-active={active}
aria-pressed={active}
aria-label={interactiveOptionLabel(
option.label,
option.description
)}
data-agent-question-option-id={option.id}
data-testid={
option.id
? `agent-question-${prompt.requestId}-${question.id}-option-${option.id}`
: undefined
}
disabled={isSubmitting || isInteractionDisabled}
onClick={() => flow.toggleOption(option.label)}
>
<span className={styles.interactiveOptionContent}>
<span className={styles.interactiveOptionTitle}>
{option.label}
</span>
{option.description ? (
<span className={styles.interactiveOptionDescription}>
{option.description}
</span>
) : null}
</span>
</button>
);
})}
</div>
) : null}
{question.allowFreeText !== false &&
(!conversationReturn || question.options.length === 0) ? (
<textarea
value={flow.freeText}
placeholder={labels.answerPlaceholder}
disabled={isSubmitting || isInteractionDisabled}
className={styles.interactivePromptTextarea}
data-testid={`agent-question-${prompt.requestId}-${question.id}-custom-answer`}
onChange={(event) => flow.setFreeText(event.currentTarget.value)}
/>
) : null}
<div className={styles.interactivePromptActions}>
{conversationReturn ? (
<Button
type="button"
variant="secondary"
size="sm"
disabled={isSubmitting}
onClick={() => setCollapsed(true)}
>
{conversationReturn.returnToConversation}
</Button>
) : null}
{prompt.questions.length > 1 ? (
<Button
type="button"
variant="secondary"
size="sm"
disabled={
isSubmitting || isInteractionDisabled || flow.currentIndex === 0
}
onClick={flow.goToPreviousQuestion}
>
{labels.previousQuestion}
</Button>
) : null}
{flow.isLastQuestion ? (
<Button
type="button"
variant="default"
size="sm"
disabled={
isSubmitting ||
isInteractionDisabled ||
!flow.allQuestionsAnswered
}
data-testid={`agent-question-${prompt.requestId}-${question.id}-submit`}
onClick={() =>
onSubmit({
requestId: prompt.requestId,
action: "submit",
payload: { ...flow.answerPayload }
})
}
>
{labels.submitAnswers}
</Button>
) : (
<Button
type="button"
variant="default"
size="sm"
disabled={
isSubmitting ||
isInteractionDisabled ||
!flow.currentQuestionAnswered
}
onClick={flow.goToNextQuestion}
>
{labels.nextQuestion}
</Button>
)}
</div>
</div>
</section>
);
}
@@ -0,0 +1,53 @@
import { useState, type JSX } from "react";
import type { AgentToolCallCardProps } from "./AgentToolCallCard";
import { AgentToolCallHeader } from "./AgentToolCallHeader";
import { CollapsibleReveal } from "./CollapsibleReveal";
import { AgentExpandedToolContent } from "./tool-renderers/AgentExpandedToolContent";
import { hasAgentToolContent } from "./tool-renderers/agentToolContentShared";
export function AgentAskUserQuestionCard({
call,
onLinkClick,
defaultExpanded,
nonCollapsible
}: AgentToolCallCardProps): JSX.Element {
"use memo";
const waitingInput =
(call.askUserQuestion?.status ?? call.status ?? "").trim().toLowerCase() ===
"waiting_input";
const hasDetail = hasAgentToolContent(call);
const pinned = nonCollapsible ?? waitingInput;
const [expanded, setExpanded] = useState(defaultExpanded ?? waitingInput);
return (
<div className="workspace-agents-status-panel__detail-tool-row workspace-agents-status-panel__detail-tool-row--ask-user">
{hasDetail && !pinned ? (
<button
type="button"
className="workspace-agents-status-panel__detail-tool-row-head workspace-agents-status-panel__detail-tool-row-head--button"
aria-expanded={expanded}
onClick={() => setExpanded((value) => !value)}
>
<AgentToolCallHeader call={call} expanded={expanded} hasDetail />
</button>
) : (
<div className="workspace-agents-status-panel__detail-tool-row-head">
<AgentToolCallHeader call={call} expanded={false} hasDetail={false} />
</div>
)}
{!hasDetail && call.summary ? (
<div className="workspace-agents-status-panel__detail-tool-summary">
{call.summary}
</div>
) : null}
{hasDetail && pinned ? (
<AgentExpandedToolContent call={call} onLinkClick={onLinkClick} />
) : null}
{hasDetail && !pinned ? (
<CollapsibleReveal expanded={expanded}>
<AgentExpandedToolContent call={call} onLinkClick={onLinkClick} />
</CollapsibleReveal>
) : null}
</div>
);
}
@@ -0,0 +1,355 @@
import { useCallback, useState, type JSX } from "react";
import {
ArrowRightLeft,
ChevronDown,
ChevronUp,
GitFork,
LoaderCircle,
MessageCircleQuestion,
Users
} from "lucide-react";
import { toast } from "@tutti-os/ui-system";
import { translate } from "../../../i18n/index";
import { useOptionalAgentHostApi } from "../../../agentActivityHost";
import { useOptionalAgentGUIRuntime } from "../../../agentActivityRuntime";
import {
AgentMessageMarkdown,
type AgentMessageMarkdownWorkspaceAppIcon
} from "../../AgentMessageMarkdown";
import type { WorkspaceLinkAction } from "../../../contexts/workspace/presentation/renderer/actions/workspaceLinkActions";
import type { AgentCollaborationVM } from "../contracts/agentCollaborationVM";
import styles from "../../../agent-gui/agentGuiNode/AgentGUIConversation.styles";
/**
* Transcript card for one durable collaboration run (model consult, fork,
* delegate, or handoff). The daemon updates the same message in place as the
* run progresses, so all durable state comes from the projected
* `AgentCollaborationVM`; the card owns only UI-local interaction state
* (result disclosure, in-flight adoption action + optimistic echo).
*/
export function AgentCollaborationRow({
collaboration,
workspaceRoot,
basePath,
onLinkAction,
workspaceAppIcons
}: {
collaboration: AgentCollaborationVM;
workspaceRoot: string | null;
basePath: string;
onLinkAction?: (action: WorkspaceLinkAction) => void;
workspaceAppIcons?: readonly AgentMessageMarkdownWorkspaceAppIcon[];
}): JSX.Element {
"use memo";
const agentHostApi = useOptionalAgentHostApi();
const runtime = useOptionalAgentGUIRuntime();
const [resultExpanded, setResultExpanded] = useState(false);
const [adoptionPending, setAdoptionPending] = useState(false);
// Optimistic echo until the daemon's in-place message update arrives.
const [localAdoption, setLocalAdoption] = useState<string | null>(null);
const adoption = localAdoption ?? collaboration.adoption;
const resultText = collaboration.resultText?.trim() ?? "";
const canSubmitAdoption = Boolean(
runtime?.setCollaborationAdoption &&
collaboration.workspaceId &&
collaboration.agentSessionId
);
const showAdoptionControls =
(collaboration.mode === "consult" || collaboration.mode === "delegate") &&
adoption !== "not_applicable" &&
collaboration.status === "completed" &&
canSubmitAdoption;
const submitAdoption = useCallback(
async (nextAdoption: "adopted" | "rejected") => {
const setCollaborationAdoption = runtime?.setCollaborationAdoption;
if (
!setCollaborationAdoption ||
!collaboration.workspaceId ||
!collaboration.agentSessionId ||
adoptionPending
) {
return;
}
setAdoptionPending(true);
try {
const run = await setCollaborationAdoption({
adoption: nextAdoption,
agentSessionId: collaboration.agentSessionId,
runId: collaboration.runId,
workspaceId: collaboration.workspaceId
});
setLocalAdoption(run.adoption);
} catch {
const message = translate(
"agentHost.agentGui.collaborationAdoptionFailed"
);
if (agentHostApi?.toast?.error) {
agentHostApi.toast.error(message);
} else {
toast.error(message);
}
} finally {
setAdoptionPending(false);
}
},
[
adoptionPending,
agentHostApi,
collaboration.agentSessionId,
collaboration.runId,
collaboration.workspaceId,
runtime
]
);
const ModeIcon = collaborationModeIcon(collaboration.mode);
const planText = collaboration.modelPlanName ?? collaboration.modelPlanId;
return (
<section
data-testid="agent-collaboration-row"
data-collaboration-run-id={collaboration.runId}
data-collaboration-mode={collaboration.mode}
data-collaboration-status={collaboration.status}
className="box-border w-full min-w-0 rounded-[8px] border border-[var(--line-2)] bg-[var(--background-fronted)] p-3 text-[13px] leading-5 text-[var(--text-primary)]"
>
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="inline-flex min-w-0 items-center gap-1.5 font-medium">
<ModeIcon
size={14}
strokeWidth={2}
aria-hidden="true"
className="shrink-0 text-[var(--text-secondary)]"
/>
<span className="truncate">
{collaborationModeLabel(collaboration.mode)}
</span>
</span>
<span
data-testid="agent-collaboration-trigger"
className="shrink-0 rounded-full border border-[var(--line-2)] px-1.5 text-[11px] leading-4 text-[var(--text-tertiary)]"
>
{collaborationTriggerLabel(collaboration.triggerSource)}
</span>
{planText || collaboration.model ? (
<span
data-testid="agent-collaboration-model"
className="min-w-0 truncate text-[12px] text-[var(--text-tertiary)]"
>
{planText
? translate("agentHost.agentGui.collaborationPlanLabel", {
name: planText
})
: null}
{planText && collaboration.model ? " · " : null}
{collaboration.model}
</span>
) : null}
<span className="min-w-0 flex-1" />
<span
data-testid="agent-collaboration-status"
data-status={collaboration.status}
className={`inline-flex shrink-0 items-center gap-1 rounded-full px-2 text-[11px] leading-[18px] ${collaborationStatusClassName(collaboration.status)}`}
>
{collaboration.status === "running" ? (
<LoaderCircle
size={11}
strokeWidth={2.2}
aria-hidden="true"
className="animate-spin"
/>
) : null}
{collaborationStatusLabel(collaboration.status)}
{collaboration.durationMs !== null &&
collaboration.status !== "running"
? ` · ${formatCollaborationDuration(collaboration.durationMs)}`
: null}
</span>
</div>
{collaboration.usage ? (
<div
data-testid="agent-collaboration-usage"
className="mt-1 text-[11px] text-[var(--text-tertiary)]"
>
{translate("agentHost.agentGui.collaborationUsageTokens", {
input: String(collaboration.usage.inputTokens),
output: String(collaboration.usage.outputTokens)
})}
</div>
) : null}
{collaboration.failureReason ? (
<div
data-testid="agent-collaboration-failure"
className="mt-1.5 text-[12px] text-[var(--state-danger)]"
>
{translate("agentHost.agentGui.collaborationFailureReason", {
reason: collaboration.failureReason
})}
</div>
) : null}
{collaboration.mode === "consult" && resultText ? (
<div className="mt-2">
<button
type="button"
data-testid="agent-collaboration-result-toggle"
aria-expanded={resultExpanded}
className="inline-flex items-center gap-1 text-[12px] text-[var(--text-secondary)] transition-colors hover:text-[var(--text-primary)]"
onClick={() => setResultExpanded((value) => !value)}
>
{translate(
resultExpanded
? "agentHost.agentGui.collaborationResultHide"
: "agentHost.agentGui.collaborationResultShow"
)}
{resultExpanded ? (
<ChevronUp size={13} strokeWidth={2} aria-hidden="true" />
) : (
<ChevronDown size={13} strokeWidth={2} aria-hidden="true" />
)}
</button>
{resultExpanded ? (
<div
data-testid="agent-collaboration-result"
className="mt-1.5 min-w-0"
>
<AgentMessageMarkdown
content={resultText}
className={styles.assistantMarkdown}
onLinkAction={onLinkAction}
workspaceLinkContext={{
workspaceRoot,
basePath,
source: "agent-markdown"
}}
workspaceAppIcons={workspaceAppIcons}
enableImageZoom
/>
</div>
) : null}
</div>
) : null}
{showAdoptionControls && adoption === "pending" ? (
<div className="mt-2 flex items-center gap-2">
<button
type="button"
data-testid="agent-collaboration-adopt"
disabled={adoptionPending}
className="inline-flex h-6 items-center rounded-[6px] border border-[var(--line-2)] px-2 text-[12px] font-medium text-[var(--text-secondary)] transition-colors hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => void submitAdoption("adopted")}
>
{translate("agentHost.agentGui.collaborationAdopt")}
</button>
<button
type="button"
data-testid="agent-collaboration-reject"
disabled={adoptionPending}
className="inline-flex h-6 items-center rounded-[6px] border border-[var(--line-2)] px-2 text-[12px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-secondary)] disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => void submitAdoption("rejected")}
>
{translate("agentHost.agentGui.collaborationReject")}
</button>
</div>
) : null}
{adoption === "adopted" || adoption === "rejected" ? (
<div
data-testid="agent-collaboration-adoption"
data-adoption={adoption}
className="mt-2 text-[11px] text-[var(--text-tertiary)]"
>
{translate(
adoption === "adopted"
? "agentHost.agentGui.collaborationAdopted"
: "agentHost.agentGui.collaborationRejected"
)}
</div>
) : null}
</section>
);
}
function collaborationModeIcon(
mode: AgentCollaborationVM["mode"]
): typeof MessageCircleQuestion {
switch (mode) {
case "fork":
return GitFork;
case "delegate":
return Users;
case "handoff":
return ArrowRightLeft;
default:
return MessageCircleQuestion;
}
}
function collaborationModeLabel(mode: AgentCollaborationVM["mode"]): string {
switch (mode) {
case "consult":
return translate("agentHost.agentGui.collaborationModeConsult");
case "fork":
return translate("agentHost.agentGui.collaborationModeFork");
case "delegate":
return translate("agentHost.agentGui.collaborationModeDelegate");
case "handoff":
return translate("agentHost.agentGui.collaborationModeHandoff");
default:
return mode;
}
}
function collaborationTriggerLabel(
triggerSource: AgentCollaborationVM["triggerSource"]
): string {
switch (triggerSource) {
case "user":
return translate("agentHost.agentGui.collaborationTriggerUser");
case "agent":
return translate("agentHost.agentGui.collaborationTriggerAgent");
case "policy":
return translate("agentHost.agentGui.collaborationTriggerPolicy");
default:
return triggerSource;
}
}
function collaborationStatusLabel(
status: AgentCollaborationVM["status"]
): string {
switch (status) {
case "running":
return translate("agentHost.agentGui.collaborationStatusRunning");
case "completed":
return translate("agentHost.agentGui.collaborationStatusCompleted");
case "failed":
return translate("agentHost.agentGui.collaborationStatusFailed");
case "canceled":
return translate("agentHost.agentGui.collaborationStatusCanceled");
default:
return status;
}
}
function collaborationStatusClassName(
status: AgentCollaborationVM["status"]
): string {
switch (status) {
case "failed":
return "bg-[var(--on-danger)] text-[var(--state-danger)]";
case "running":
return "bg-[color-mix(in_srgb,var(--tutti-purple)_12%,transparent)] text-[var(--tutti-purple)]";
default:
return "bg-[var(--transparency-hover)] text-[var(--text-secondary)]";
}
}
function formatCollaborationDuration(durationMs: number): string {
const totalSeconds = Math.max(0, Math.round(durationMs / 1000));
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}m ${seconds}s`;
}
@@ -0,0 +1,101 @@
import { act, render, screen } from "@testing-library/react";
import type { JSX } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
AgentConversationClockProvider,
useAgentConversationMinuteNowUnixMs
} from "./AgentConversationClock";
import { useElapsedSeconds } from "./useElapsedSeconds";
describe("AgentConversationClock", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
});
afterEach(() => {
vi.useRealTimers();
});
it("shares one interval and pauses it while the AgentGUI is hidden", () => {
const setInterval = vi.spyOn(window, "setInterval");
const clearInterval = vi.spyOn(window, "clearInterval");
const { rerender } = render(<ElapsedPair isVisible />);
expect(screen.getByTestId("elapsed-a")).toHaveTextContent("0");
expect(screen.getByTestId("elapsed-b")).toHaveTextContent("0");
expect(setInterval).toHaveBeenCalledTimes(1);
act(() => {
vi.advanceTimersByTime(2_000);
});
expect(screen.getByTestId("elapsed-a")).toHaveTextContent("2");
expect(screen.getByTestId("elapsed-b")).toHaveTextContent("2");
rerender(<ElapsedPair isVisible={false} />);
expect(clearInterval).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
act(() => {
vi.advanceTimersByTime(5_000);
});
expect(vi.getTimerCount()).toBe(0);
rerender(<ElapsedPair isVisible />);
expect(screen.getByTestId("elapsed-a")).toHaveTextContent("7");
expect(screen.getByTestId("elapsed-b")).toHaveTextContent("7");
expect(setInterval).toHaveBeenCalledTimes(2);
});
it("pauses minute updates while the AgentGUI is hidden", () => {
const setInterval = vi.spyOn(window, "setInterval");
const clearInterval = vi.spyOn(window, "clearInterval");
const { rerender } = render(<MinuteValue isVisible />);
expect(screen.getByTestId("minute-now")).toHaveTextContent("1000");
expect(setInterval).toHaveBeenCalledTimes(1);
act(() => {
vi.advanceTimersByTime(60_000);
});
expect(screen.getByTestId("minute-now")).toHaveTextContent("61000");
rerender(<MinuteValue isVisible={false} />);
expect(clearInterval).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
act(() => {
vi.advanceTimersByTime(120_000);
});
expect(screen.getByTestId("minute-now")).toHaveTextContent("61000");
rerender(<MinuteValue isVisible />);
expect(screen.getByTestId("minute-now")).toHaveTextContent("181000");
expect(setInterval).toHaveBeenCalledTimes(2);
});
});
function ElapsedPair({ isVisible }: { isVisible: boolean }): JSX.Element {
return (
<AgentConversationClockProvider isVisible={isVisible}>
<ElapsedValue testId="elapsed-a" />
<ElapsedValue testId="elapsed-b" />
</AgentConversationClockProvider>
);
}
function ElapsedValue({ testId }: { testId: string }): JSX.Element {
const elapsedSeconds = useElapsedSeconds(1_000);
return <span data-testid={testId}>{elapsedSeconds}</span>;
}
function MinuteValue({ isVisible }: { isVisible: boolean }): JSX.Element {
return (
<AgentConversationClockProvider isVisible={isVisible}>
<MinuteNow />
</AgentConversationClockProvider>
);
}
function MinuteNow(): JSX.Element {
const nowUnixMs = useAgentConversationMinuteNowUnixMs();
return <span data-testid="minute-now">{nowUnixMs}</span>;
}
@@ -0,0 +1,107 @@
import { createContext, useContext, type JSX, type ReactNode } from "react";
import {
useExternalStoreSnapshot,
type ExternalStoreSnapshotSource
} from "@tutti-os/ui-react-hooks";
const AgentConversationClockVisibilityContext = createContext(true);
type ClockListener = () => void;
class AgentConversationClockStore {
private readonly minuteListeners = new Set<ClockListener>();
private readonly secondListeners = new Set<ClockListener>();
private minuteTimeMs = Date.now();
private minuteTimer: number | null = null;
private secondTimeMs = Date.now();
private secondTimer: number | null = null;
readonly disabled: ExternalStoreSnapshotSource<number> = {
getSnapshot: () => 0,
subscribe: () => () => undefined
};
readonly minute: ExternalStoreSnapshotSource<number> = {
getSnapshot: () => this.minuteTimeMs,
subscribe: (listener) =>
this.subscribe(
listener,
this.minuteListeners,
60_000,
"minuteTimeMs",
"minuteTimer"
)
};
readonly second: ExternalStoreSnapshotSource<number> = {
getSnapshot: () => this.secondTimeMs,
subscribe: (listener) =>
this.subscribe(
listener,
this.secondListeners,
1_000,
"secondTimeMs",
"secondTimer"
)
};
private subscribe(
listener: ClockListener,
listeners: Set<ClockListener>,
intervalMs: number,
timeKey: "minuteTimeMs" | "secondTimeMs",
timerKey: "minuteTimer" | "secondTimer"
): () => void {
listeners.add(listener);
if (listeners.size === 1) {
this[timeKey] = Date.now();
// timing: share one cadence timer across all mounted consumers.
this[timerKey] = window.setInterval(() => {
this[timeKey] = Date.now();
listeners.forEach((candidate) => candidate());
}, intervalMs);
}
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
const timer = this[timerKey];
if (timer !== null) {
window.clearInterval(timer);
this[timerKey] = null;
}
}
};
}
}
const agentConversationClock = new AgentConversationClockStore();
export function AgentConversationClockProvider({
children,
isVisible
}: {
children: ReactNode;
isVisible: boolean;
}): JSX.Element {
return (
<AgentConversationClockVisibilityContext.Provider value={isVisible}>
{children}
</AgentConversationClockVisibilityContext.Provider>
);
}
export function useAgentConversationNowUnixMs(enabled: boolean): number | null {
const isVisible = useContext(AgentConversationClockVisibilityContext);
const shouldTick = enabled && isVisible;
const nowUnixMs = useExternalStoreSnapshot(
shouldTick ? agentConversationClock.second : agentConversationClock.disabled
);
return shouldTick ? nowUnixMs : null;
}
export function useAgentConversationMinuteNowUnixMs(): number {
const isVisible = useContext(AgentConversationClockVisibilityContext);
const nowUnixMs = useExternalStoreSnapshot(
isVisible ? agentConversationClock.minute : agentConversationClock.disabled
);
return isVisible ? nowUnixMs : agentConversationClock.minute.getSnapshot();
}
@@ -0,0 +1,137 @@
import { memo, type ReactNode, type JSX, type Ref } from "react";
import type { WorkspaceLinkAction } from "../../../contexts/workspace/presentation/renderer/actions/workspaceLinkActions";
import type { AgentMessageMarkdownWorkspaceAppIcon } from "../../AgentMessageMarkdown";
import {
AgentTargetPresentationProvider,
type AgentMessageMarkdownAgentTarget
} from "../../AgentTargetPresentationContext";
import type { AgentConversationVM } from "../contracts/agentConversationVM";
import type { AgentConversationParticipantPresentation } from "../contracts/agentConversationParticipantPresentation";
import type { AgentConversationFollowEndMode } from "../agentConversationFollowEndController";
import { AgentTranscriptSkeleton } from "./AgentTranscriptSkeleton";
import {
AgentTranscriptView,
type AgentTranscriptAttachmentLocator,
type AgentTranscriptTurnAttachment,
type AgentTranscriptVirtualScrollController
} from "./AgentTranscriptView";
import type { AgentTranscriptEditRetryControl } from "./useAgentTranscriptEditRetryProjection";
import { AgentConversationClockProvider } from "./AgentConversationClock";
import { AgentTurnDisclosureProvider } from "./AgentTurnDisclosureContext";
import type { AgentGUIProviderSkillOption } from "../../../agent-gui/agentGuiNode/model/agentGuiNodeTypes";
export interface AgentConversationFlowProps {
conversation: AgentConversationVM | null;
editRetry?: AgentTranscriptEditRetryControl;
turnAttachments?: readonly AgentTranscriptTurnAttachment[];
turnAttachmentLocatorRef?: Ref<AgentTranscriptAttachmentLocator>;
onTurnAttachmentVisibilityChange?: (
attachmentId: string,
visible: boolean
) => void;
isLoading: boolean;
isVisible?: boolean;
loadingLabel: string;
loadingTestId?: string;
empty: ReactNode;
onLinkAction?: (action: WorkspaceLinkAction) => void;
onAuthLogin?: (provider?: string | null) => void;
onForkThroughTurn?: (turnId: string) => void;
availableSkills?: readonly AgentGUIProviderSkillOption[];
agentTargets?: readonly AgentMessageMarkdownAgentTarget[];
workspaceAppIcons?: readonly AgentMessageMarkdownWorkspaceAppIcon[];
showRawTimelineJson?: boolean;
participantPresentation?: AgentConversationParticipantPresentation;
followEndMode?: AgentConversationFollowEndMode;
forkThroughTurnPendingTurnIds?: readonly string[];
virtualListLayoutRevision?: number;
virtualScrollControllerRef?: Ref<AgentTranscriptVirtualScrollController>;
labels: {
toolCallsLabel: (count: number) => string;
thinkingLabel: string;
processing: string;
turnSummary: string;
rawTimelineJson?: string;
userMessageLocator?: string;
};
}
export const AgentConversationFlow = memo(function AgentConversationFlow({
conversation,
editRetry,
turnAttachments,
turnAttachmentLocatorRef,
onTurnAttachmentVisibilityChange,
isLoading,
isVisible = true,
loadingLabel,
loadingTestId,
empty,
onLinkAction,
onAuthLogin,
onForkThroughTurn,
availableSkills,
agentTargets,
workspaceAppIcons,
showRawTimelineJson = false,
participantPresentation,
followEndMode,
forkThroughTurnPendingTurnIds,
virtualListLayoutRevision,
virtualScrollControllerRef,
labels
}: AgentConversationFlowProps): JSX.Element {
"use memo";
let content: JSX.Element;
if (isLoading) {
content = (
<AgentTranscriptSkeleton label={loadingLabel} testId={loadingTestId} />
);
} else if (
!conversation ||
(conversation.rows.length === 0 && !turnAttachments?.length)
) {
content = <>{empty}</>;
} else {
content = (
<AgentTranscriptView
conversation={conversation}
isVisible={isVisible}
editRetry={editRetry}
turnAttachments={turnAttachments}
turnAttachmentLocatorRef={turnAttachmentLocatorRef}
onTurnAttachmentVisibilityChange={onTurnAttachmentVisibilityChange}
onLinkAction={onLinkAction}
onAuthLogin={onAuthLogin}
onForkThroughTurn={onForkThroughTurn}
forkThroughTurnPendingTurnIds={forkThroughTurnPendingTurnIds}
availableSkills={availableSkills}
workspaceAppIcons={workspaceAppIcons}
labels={labels}
showRawTimelineJson={showRawTimelineJson}
followEndMode={followEndMode}
participantPresentation={participantPresentation}
virtualListLayoutRevision={virtualListLayoutRevision}
virtualScrollControllerRef={virtualScrollControllerRef}
/>
);
}
const disclosedContent = (
<AgentTurnDisclosureProvider>{content}</AgentTurnDisclosureProvider>
);
const presentedContent =
agentTargets === undefined ? (
disclosedContent
) : (
<AgentTargetPresentationProvider agentTargets={agentTargets}>
{disclosedContent}
</AgentTargetPresentationProvider>
);
return (
<AgentConversationClockProvider isVisible={isVisible}>
{presentedContent}
</AgentConversationClockProvider>
);
});
@@ -0,0 +1,90 @@
import type { JSX } from "react";
import { Avatar } from "@tutti-os/ui-system";
import type { AgentMessageRowVM } from "../contracts/agentMessageRowVM";
import type {
AgentConversationParticipantIdentity,
AgentConversationParticipantPresentation
} from "../contracts/agentConversationParticipantPresentation";
import styles from "../../../agent-gui/agentGuiNode/AgentGUIConversation.styles";
export function AgentConversationParticipantHeader({
presentation,
speaker
}: {
presentation: Extract<
AgentConversationParticipantPresentation,
{ enabled: true }
>;
speaker: AgentMessageRowVM["speaker"];
}): JSX.Element {
const participant: AgentConversationParticipantIdentity | null =
presentation.status === "loading"
? null
: speaker === "user"
? presentation.user
: presentation.agent;
const nameContent = participant ? (
<span className={styles.participantName}>{participant.name}</span>
) : null;
const avatarContent = (
<AgentConversationParticipantAvatar
presentation={presentation}
speaker={speaker}
/>
);
return (
<div
className={styles.participantMessageHeader}
data-agent-conversation-participant-header={speaker}
>
{speaker === "user" ? (
<>
{nameContent}
{avatarContent}
</>
) : (
<>
{avatarContent}
{nameContent}
</>
)}
</div>
);
}
function AgentConversationParticipantAvatar({
presentation,
speaker
}: {
presentation: Extract<
AgentConversationParticipantPresentation,
{ enabled: true }
>;
speaker: AgentMessageRowVM["speaker"];
}): JSX.Element {
if (presentation.status === "loading") {
return (
<Avatar
aria-hidden="true"
className={styles.participantAvatar}
data-agent-conversation-participant-avatar={speaker}
label=""
loading
size={28}
/>
);
}
const participant: AgentConversationParticipantIdentity =
speaker === "user" ? presentation.user : presentation.agent;
return (
<Avatar
aria-label={participant.name}
className={styles.participantAvatar}
data-agent-conversation-participant-avatar={speaker}
label={participant.name}
size={28}
src={participant.avatarUrl}
/>
);
}
@@ -0,0 +1,29 @@
import type { JSX } from "react";
import type { AgentToolCallCardProps } from "./AgentToolCallCard";
import { AgentToolCallHeader } from "./AgentToolCallHeader";
import { AgentExpandedToolContent } from "./tool-renderers/AgentExpandedToolContent";
import { hasAgentToolContent } from "./tool-renderers/agentToolContentShared";
export function AgentEnterPlanModeCard({
call,
onLinkClick
}: AgentToolCallCardProps): JSX.Element {
"use memo";
const hasDetail = hasAgentToolContent(call);
return (
<div className="workspace-agents-status-panel__detail-tool-row workspace-agents-status-panel__detail-tool-row--plan-enter">
<div className="workspace-agents-status-panel__detail-tool-row-head">
<AgentToolCallHeader call={call} expanded={false} hasDetail={false} />
</div>
{!hasDetail && call.summary ? (
<div className="workspace-agents-status-panel__detail-tool-summary">
{call.summary}
</div>
) : null}
{hasDetail ? (
<AgentExpandedToolContent call={call} onLinkClick={onLinkClick} />
) : null}
</div>
);
}
@@ -0,0 +1,29 @@
import type { JSX } from "react";
import type { AgentToolCallCardProps } from "./AgentToolCallCard";
import { AgentToolCallHeader } from "./AgentToolCallHeader";
import { AgentExpandedToolContent } from "./tool-renderers/AgentExpandedToolContent";
import { hasAgentToolContent } from "./tool-renderers/agentToolContentShared";
export function AgentExitPlanModeCard({
call,
onLinkClick
}: AgentToolCallCardProps): JSX.Element {
"use memo";
const hasDetail = hasAgentToolContent(call);
return (
<div className="workspace-agents-status-panel__detail-tool-row workspace-agents-status-panel__detail-tool-row--plan-exit">
<div className="workspace-agents-status-panel__detail-tool-row-head">
<AgentToolCallHeader call={call} expanded={false} hasDetail={false} />
</div>
{!hasDetail && call.summary ? (
<div className="workspace-agents-status-panel__detail-tool-summary">
{call.summary}
</div>
) : null}
{hasDetail ? (
<AgentExpandedToolContent call={call} onLinkClick={onLinkClick} />
) : null}
</div>
);
}
@@ -0,0 +1,73 @@
import { GitFork, LoaderCircle } from "lucide-react";
import type { JSX } from "react";
import { translate } from "../../../i18n/index";
import { CanvasNodeGhostIconButton } from "../../../contexts/workspace/presentation/renderer/components/shared/CanvasNodeGhostIconButton";
import styles from "../../../agent-gui/agentGuiNode/AgentGUIConversation.styles";
export function AgentForkThroughTurnButton({
disabled,
pending = false,
revealOnMessageHover = true,
onFork
}: {
disabled?: boolean;
pending?: boolean;
revealOnMessageHover?: boolean;
onFork: () => void;
}): JSX.Element {
const label = translate(
pending
? "agentHost.agentGui.forkThroughTurnPending"
: "agentHost.agentGui.forkThroughTurn"
);
return (
<CanvasNodeGhostIconButton
className={
revealOnMessageHover
? styles.messageCopyButton
: "static h-[22px] min-h-[22px] w-[22px] min-w-[22px] rounded-[5px]"
}
aria-busy={pending || undefined}
aria-label={label}
aria-live="polite"
data-pending={pending || undefined}
disabled={disabled || pending}
onClick={onFork}
>
{pending ? (
<LoaderCircle
width={14}
height={14}
className="animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<GitFork width={14} height={14} aria-hidden="true" />
)}
</CanvasNodeGhostIconButton>
);
}
export function AgentForkThroughTurnFooter({
disabled,
pending,
onFork
}: {
disabled?: boolean;
pending?: boolean;
onFork: () => void;
}): JSX.Element {
return (
<div
className="mt-1 flex min-h-[26px] items-center"
data-agent-turn-footer="true"
>
<AgentForkThroughTurnButton
disabled={disabled}
pending={pending}
revealOnMessageHover={false}
onFork={onFork}
/>
</div>
);
}
@@ -0,0 +1,99 @@
import { useEffect, useState, type JSX } from "react";
import { resolveWorkspaceImageMimeType } from "@tutti-os/workspace-file-preview";
import { useOptionalAgentHostApi } from "../../../agentActivityHost";
import { ZoomableImage } from "../../../app/renderer/components/ZoomableImage";
import {
isLocalImagePath,
resolveImageGenerationPreviewSrc
} from "../../imageGenerationTool";
interface AgentGeneratedImagePreviewProps {
uri: string;
mimeType: string | null;
alt: string;
className: string;
}
export function AgentGeneratedImagePreview({
uri,
mimeType,
alt,
className
}: AgentGeneratedImagePreviewProps): JSX.Element | null {
"use memo";
const agentHostApi = useOptionalAgentHostApi();
const localPath = isLocalImagePath(uri) ? uri.trim() : null;
const readWorkspaceImage = localPath
? agentHostApi?.workspace?.readFile
: undefined;
const [src, setSrc] = useState<string | null>(() =>
!localPath ? resolveImageGenerationPreviewSrc(uri) : null
);
useEffect(() => {
if (!localPath || !readWorkspaceImage) {
setSrc(resolveImageGenerationPreviewSrc(uri));
return;
}
const resolvedLocalPath = localPath;
const resolvedReadWorkspaceImage = readWorkspaceImage;
let canceled = false;
let objectUrl: string | null = null;
const resolvedMimeType =
mimeType?.trim() ||
resolveWorkspaceImageMimeType(resolvedLocalPath) ||
"image/png";
async function loadWorkspaceImage(): Promise<void> {
try {
const result = await resolvedReadWorkspaceImage({
path: resolvedLocalPath
});
if (canceled) {
return;
}
const bytes =
result.bytes instanceof Uint8Array
? result.bytes
: new Uint8Array(result.bytes);
const arrayBuffer = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength
) as ArrayBuffer;
objectUrl = URL.createObjectURL(
new Blob([arrayBuffer], { type: resolvedMimeType })
);
setSrc(objectUrl);
} catch {
if (!canceled) {
setSrc(null);
}
}
}
setSrc(null);
void loadWorkspaceImage();
return () => {
canceled = true;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [localPath, mimeType, readWorkspaceImage, uri]);
if (!src) {
return null;
}
return (
<ZoomableImage
alt={alt}
className={className}
downloadName={localPath ? localPath.split(/[\\/]/).pop() : "image.png"}
src={src}
wrapElement="span"
/>
);
}
@@ -0,0 +1,25 @@
import type { JSX } from "react";
import { translate } from "../../../i18n/index";
import type { AgentGeneratedImageRowVM } from "../contracts/agentGeneratedImageRowVM";
import { AgentGeneratedImagePreview } from "./AgentGeneratedImagePreview";
export function AgentGeneratedImageRow({
row
}: {
row: AgentGeneratedImageRowVM;
}): JSX.Element {
"use memo";
return (
<div
className="flex max-w-full justify-start"
data-testid="agent-generated-image-artifact"
>
<AgentGeneratedImagePreview
uri={row.uri}
mimeType={row.mimeType}
alt={translate("agentHost.agentTool.details.imagePreviewAlt")}
className="block max-h-[560px] max-w-full rounded-[10px] border border-[var(--line-2)] bg-[var(--background-panel)] object-contain"
/>
</div>
);
}
@@ -0,0 +1,71 @@
import { useCallback, type JSX } from "react";
import type { AgentMessageMarkdownWorkspaceAppIcon } from "../../AgentMessageMarkdown";
import { AgentRichTextReadonly } from "../../AgentRichTextReadonly";
import { useOptionalAgentHostApi } from "../../../agentActivityHost";
import type { AgentGUIProviderSkillOption } from "../../../agent-gui/agentGuiNode/model/agentGuiNodeTypes";
import styles from "../../../agent-gui/agentGuiNode/AgentGUIConversation.styles";
import type { AgentGoalControlRowVM } from "../contracts/agentGoalControlRowVM";
import { AgentCopyableMessageGroup } from "./AgentMessageActions";
interface AgentGoalControlRowProps {
row: AgentGoalControlRowVM;
availableSkills?: readonly AgentGUIProviderSkillOption[];
workspaceAppIcons?: readonly AgentMessageMarkdownWorkspaceAppIcon[];
}
export function AgentGoalControlRow({
row,
availableSkills,
workspaceAppIcons
}: AgentGoalControlRowProps): JSX.Element {
"use memo";
const agentHostApi = useOptionalAgentHostApi();
const handleCopyMessageText = useCallback(
async (text: string): Promise<boolean> => {
if (!text.trim()) {
return false;
}
try {
const hostWriteText = agentHostApi?.clipboard?.writeText;
if (typeof hostWriteText === "function") {
await hostWriteText(text);
return true;
}
if (
typeof navigator !== "undefined" &&
typeof navigator.clipboard?.writeText === "function"
) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
return false;
}
return false;
},
[agentHostApi]
);
return (
<div
className={styles.userMessageFlow}
data-agent-goal-control-action={row.action}
>
<AgentCopyableMessageGroup
copyText={row.body.trim() ? row.body : null}
editAction={null}
occurredAtUnixMs={row.occurredAtUnixMs}
onCopyMessageText={handleCopyMessageText}
speaker="user"
>
<AgentRichTextReadonly
value={row.body}
className={`workspace-agents-status-panel__detail-user-message ${styles.userMessageBubble}`}
editorClassName="text-[inherit]"
availableSkills={availableSkills}
workspaceAppIcons={workspaceAppIcons}
/>
</AgentCopyableMessageGroup>
</div>
);
}
@@ -0,0 +1,626 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, ShortcutBadge } from "@tutti-os/ui-system";
import {
getOptionalAgentHostApi,
useOptionalAgentHostApi
} from "../../../agentActivityHost";
import {
approvalOptionDisplayLabel,
approvalOptionVisualPresentation
} from "../approvalOptionPresentation";
import type { AgentConversationPromptVM } from "../contracts/agentConversationVM";
import {
PLAN_IMPLEMENTATION_ACTION_FEEDBACK,
PLAN_IMPLEMENTATION_ACTION_IMPLEMENT,
PLAN_IMPLEMENTATION_ACTION_SKIP
} from "../planImplementationPresentation";
import type { AgentInteractivePromptSurfaceProps } from "./AgentInteractivePromptSurface";
import {
approvalFeedbackOptionId,
approvalOptionShortcutLabel,
CommandTextWithTooltip,
formatApprovalToolPresentation,
InteractiveOptionSpinner,
interactiveOptionLabel,
interactivePromptCardClassName,
interactivePromptClassName,
isDenyApprovalOptionToken,
isDarwinPlatform,
isEditableKeyboardTarget,
isEnterLikeKey,
PromptDetailValue,
SendFilledIcon,
stripPromptTitlePunctuation
} from "./interactivePromptPresentation";
import styles from "../../../agent-gui/agentGuiNode/AgentGUIConversation.styles";
export function ApprovalPromptSurface({
prompt,
embedded = false,
edgeGlow = false,
keyboardShortcuts = true,
isSubmitting,
isInteractionDisabled = false,
onSubmit,
labels
}: AgentInteractivePromptSurfaceProps & {
prompt: Extract<AgentConversationPromptVM, { kind: "approval" }>;
embedded?: boolean;
}) {
"use memo";
const promptToolPresentation = useMemo(
() => formatApprovalToolPresentation(prompt, labels),
[labels, prompt]
);
const [submittingOptionId, setSubmittingOptionId] = useState<string | null>(
null
);
const [pendingFeedbackOptionId, setPendingFeedbackOptionId] = useState<
string | null
>(null);
const [feedback, setFeedback] = useState("");
const feedbackTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const agentHostApi = useOptionalAgentHostApi() ?? getOptionalAgentHostApi();
const isDarwin = isDarwinPlatform(agentHostApi?.meta?.platform);
const controlsDisabled = isSubmitting || isInteractionDisabled;
const feedbackOptionId = useMemo(
() => approvalFeedbackOptionId(prompt.options),
[prompt.options]
);
const feedbackValue = feedback.trim();
const approveOptionId = useMemo(
() =>
prompt.options.find(
(option) =>
option.id !== feedbackOptionId &&
!isDenyApprovalOptionToken(option.id) &&
!isDenyApprovalOptionToken(option.kind)
)?.id ?? null,
[feedbackOptionId, prompt.options]
);
const denyOptionId = useMemo(
() =>
prompt.options.find(
(option) =>
option.id !== feedbackOptionId &&
(isDenyApprovalOptionToken(option.id) ||
isDenyApprovalOptionToken(option.kind))
)?.id ?? null,
[feedbackOptionId, prompt.options]
);
useEffect(() => {
setSubmittingOptionId(null);
setPendingFeedbackOptionId(null);
setFeedback("");
}, [prompt.requestId]);
useEffect(() => {
if (!isSubmitting) {
setSubmittingOptionId(null);
}
}, [isSubmitting]);
useEffect(() => {
if (pendingFeedbackOptionId !== null) {
feedbackTextareaRef.current?.focus();
}
}, [pendingFeedbackOptionId]);
const submitOption = useCallback(
(optionId: string) => {
if (controlsDisabled) {
return;
}
const feedbackOption = feedbackOptionId === optionId;
if (feedbackOption && pendingFeedbackOptionId !== optionId) {
setFeedback("");
setPendingFeedbackOptionId(optionId);
return;
}
setSubmittingOptionId(optionId);
onSubmit({
requestId: prompt.requestId,
...(feedbackOption ? { action: "deny" } : {}),
optionId,
...(feedbackOption && feedbackValue
? { payload: { denyMessage: feedbackValue } }
: {})
});
},
[
feedbackOptionId,
feedbackValue,
controlsDisabled,
onSubmit,
pendingFeedbackOptionId,
prompt.requestId
]
);
useEffect(() => {
if (
!keyboardShortcuts ||
controlsDisabled ||
submittingOptionId !== null ||
prompt.options.length === 0
) {
return undefined;
}
const handleKeyDown = (event: KeyboardEvent): void => {
if (
!isEnterLikeKey(event) ||
isEditableKeyboardTarget(event.target) ||
event.isComposing ||
event.altKey ||
event.shiftKey
) {
return;
}
const optionIndex = event.metaKey || event.ctrlKey ? 1 : 0;
const option = prompt.options[optionIndex];
if (!option) {
return;
}
event.preventDefault();
event.stopPropagation();
submitOption(option.id);
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [
controlsDisabled,
keyboardShortcuts,
prompt.options,
submitOption,
submittingOptionId
]);
return (
<section
className={interactivePromptClassName(embedded)}
data-agent-interaction-id={prompt.requestId}
data-agent-interaction-kind="approval"
data-testid={`agent-interaction-${prompt.requestId}`}
>
<div className={interactivePromptCardClassName(edgeGlow)}>
<div className={styles.interactivePromptLeadContent}>
<div className={styles.interactivePromptLead}>
{stripPromptTitlePunctuation(promptToolPresentation.lead)}
</div>
{promptToolPresentation.leadDetails.map((detail) => (
<div
key={`${detail.kind}:${detail.value}`}
className={styles.interactivePromptQuestion}
>
{detail.value}
{detail.meta ? ` ${detail.meta}` : null}
</div>
))}
</div>
{promptToolPresentation.cardDetails.length > 0 ? (
<div className={styles.interactivePromptOptions}>
{promptToolPresentation.cardDetails.map((detail) => (
<div
key={`${detail.label}:${detail.value}`}
className={styles.interactiveOptionDisplay}
>
<span className={styles.interactiveOptionTitle}>
{detail.label}
</span>
<PromptDetailValue detail={detail} />
{detail.meta ? (
<span className={styles.interactiveOptionDescription}>
{detail.meta}
</span>
) : null}
</div>
))}
</div>
) : null}
<div className={styles.interactivePromptOptions}>
{prompt.options.map((option, optionIndex) => {
const showSpinner = submittingOptionId === option.id;
const optionSupportsFeedback = feedbackOptionId === option.id;
const optionLabel = approvalOptionDisplayLabel(option, {
feedback: optionSupportsFeedback
});
const optionPresentation = approvalOptionVisualPresentation(
option,
{ feedback: optionSupportsFeedback }
);
const shortcutLabel = approvalOptionShortcutLabel(
optionIndex,
isDarwin
);
const showFeedbackComposer = pendingFeedbackOptionId === option.id;
const action = approvalActionTestId(
option.id,
approveOptionId,
denyOptionId,
feedbackOptionId
);
if (showFeedbackComposer) {
return (
<div
key={option.id}
className={styles.interactiveFeedbackComposer}
>
<textarea
ref={feedbackTextareaRef}
value={feedback}
placeholder={labels.feedbackPlaceholder}
disabled={controlsDisabled || submittingOptionId !== null}
className={styles.interactivePromptTextarea}
aria-label={interactiveOptionLabel(
optionLabel,
option.description
)}
data-testid={`agent-approval-${prompt.requestId}-${action}-input`}
onChange={(event) => setFeedback(event.currentTarget.value)}
onKeyDown={(event) => {
if (
event.key !== "Enter" ||
event.shiftKey ||
event.nativeEvent.isComposing
) {
return;
}
event.preventDefault();
if (!feedbackValue) {
feedbackTextareaRef.current?.focus();
return;
}
submitOption(option.id);
}}
/>
<button
type="button"
className={styles.interactiveFeedbackSendButton}
disabled={
controlsDisabled ||
submittingOptionId !== null ||
!feedbackValue
}
aria-label={labels.sendFeedback}
title={labels.sendFeedback}
aria-busy={showSpinner}
data-testid={`agent-approval-${prompt.requestId}-${action}-submit`}
onClick={() => {
if (!feedbackValue) {
feedbackTextareaRef.current?.focus();
return;
}
submitOption(option.id);
}}
>
{showSpinner ? (
<InteractiveOptionSpinner />
) : (
<SendFilledIcon />
)}
</button>
</div>
);
}
return (
<button
key={option.id}
type="button"
className={styles.interactiveOptionButton}
aria-label={interactiveOptionLabel(
optionLabel,
option.description
)}
data-agent-approval-action={action}
data-agent-approval-option-id={option.id}
data-testid={`agent-approval-${prompt.requestId}-${action}`}
disabled={controlsDisabled || submittingOptionId !== null}
onClick={() => submitOption(option.id)}
>
<span className={styles.interactiveOptionContent}>
<span className={styles.interactiveOptionTitle}>
{optionPresentation.label}
</span>
{optionPresentation.commandPrefix ? (
<CommandTextWithTooltip
value={optionPresentation.commandPrefix}
testId="agent-interactive-command-prefix-option"
tooltipsEnabled
/>
) : null}
{option.description ? (
<span className={styles.interactiveOptionDescription}>
{option.description}
</span>
) : null}
</span>
{keyboardShortcuts && shortcutLabel && !showSpinner ? (
<ShortcutBadge
className={styles.interactiveOptionShortcut}
aria-hidden="true"
>
{shortcutLabel}
</ShortcutBadge>
) : null}
{showSpinner ? <InteractiveOptionSpinner /> : null}
</button>
);
})}
</div>
</div>
</section>
);
}
function approvalActionTestId(
optionID: string,
approveOptionID: string | null,
denyOptionID: string | null,
feedbackOptionID: string | null
): string {
if (optionID === feedbackOptionID) return "feedback";
if (optionID === denyOptionID) return "deny";
if (optionID === approveOptionID) return "approve";
return `option-${optionID}`;
}
export function ExitPlanPromptSurface({
prompt,
variant = "full",
embedded = false,
edgeGlow = false,
isSubmitting,
isInteractionDisabled = false,
onSubmit,
labels
}: AgentInteractivePromptSurfaceProps & {
prompt: Extract<AgentConversationPromptVM, { kind: "exit-plan" }>;
embedded?: boolean;
}) {
"use memo";
const [feedback, setFeedback] = useState("");
// Compact (message-center deck): only the permission-mode decision buttons are
// offered; refining / staying in plan is deferred to the conversation.
const showFeedbackFooter = variant !== "compact";
const [submittingOptionId, setSubmittingOptionId] = useState<string | null>(
null
);
const trimmed = feedback.trim();
const continueLabel =
trimmed === "" ? labels.stayInPlan : labels.sendFeedback;
const controlsDisabled = isSubmitting || isInteractionDisabled;
// Render the permission modes the runtime actually offered ("Yes, and ...")
// so newly added modes (e.g. "auto") appear automatically. Localized
// label/description are looked up by option id, falling back to the runtime's
// own label for ids we don't have copy for. Only when the runtime sent no
// options (Codex plan / legacy exitplanmode) do we use the curated defaults.
const modes =
prompt.options.length > 0
? prompt.options.map((option) => {
const known = labels.planModes.find((mode) => mode.id === option.id);
return {
id: option.id,
label: known?.label ?? option.label,
description: known?.description ?? option.description ?? ""
};
})
: labels.planModes;
useEffect(() => {
setSubmittingOptionId(null);
}, [prompt.requestId]);
useEffect(() => {
if (!isSubmitting) {
setSubmittingOptionId(null);
}
}, [isSubmitting]);
return (
<section
className={interactivePromptClassName(embedded)}
data-agent-interaction-id={prompt.requestId}
data-agent-interaction-kind="exit-plan"
data-testid={`agent-exit-plan-${prompt.requestId}`}
>
<div className={interactivePromptCardClassName(edgeGlow)}>
<div className={styles.interactivePromptLead}>
{stripPromptTitlePunctuation(labels.planLead)}
</div>
<div className={styles.interactivePromptOptions}>
{modes.map((mode) => {
const showSpinner = submittingOptionId === mode.id;
return (
<button
key={mode.id}
type="button"
className={styles.interactiveOptionButton}
aria-label={interactiveOptionLabel(
mode.label,
mode.description
)}
data-testid={`agent-exit-plan-confirm-${mode.id}`}
disabled={controlsDisabled || submittingOptionId !== null}
onClick={() => {
setSubmittingOptionId(mode.id);
onSubmit({
requestId: prompt.requestId,
action: "allow",
optionId: mode.id
});
}}
>
<span className={styles.interactiveOptionContent}>
<span className={styles.interactiveOptionTitle}>
{mode.label}
</span>
<span className={styles.interactiveOptionDescription}>
{mode.description}
</span>
</span>
{showSpinner ? <InteractiveOptionSpinner /> : null}
</button>
);
})}
</div>
{showFeedbackFooter ? (
<div className={styles.interactivePromptFooter}>
<textarea
value={feedback}
placeholder={labels.feedbackPlaceholder}
disabled={controlsDisabled}
className={styles.interactivePromptTextarea}
data-testid="agent-exit-plan-feedback"
onChange={(event) => setFeedback(event.currentTarget.value)}
/>
<div className={styles.interactivePromptActions}>
<Button
type="button"
variant="secondary"
size="sm"
disabled={controlsDisabled}
data-testid="agent-exit-plan-keep-planning"
onClick={() =>
onSubmit({
requestId: prompt.requestId,
// The runtime models exit-plan as an approval that requires
// an option id, so "keep planning" submits its `plan` option
// id (when present) rather than a bare deny. `action: deny`
// is kept so the controller doesn't flip plan mode off.
action: "deny",
...(prompt.keepPlanningOptionId
? { optionId: prompt.keepPlanningOptionId }
: {}),
payload: trimmed ? { denyMessage: trimmed } : undefined
})
}
>
{continueLabel}
</Button>
</div>
</div>
) : (
// Compact (deck): no textarea, but keep declining reachable — the deck
// must still let the user keep planning (refining/feedback is deferred
// to the conversation via the card's "open conversation" jump).
<div className={styles.interactivePromptActions}>
<Button
type="button"
variant="secondary"
size="sm"
disabled={controlsDisabled}
data-testid="agent-exit-plan-keep-planning"
onClick={() =>
onSubmit({
requestId: prompt.requestId,
action: "deny",
...(prompt.keepPlanningOptionId
? { optionId: prompt.keepPlanningOptionId }
: {})
})
}
>
{labels.stayInPlan}
</Button>
</div>
)}
</div>
</section>
);
}
// Codex plan-mode "implement this plan?" decision. No server request; actions
// are routed by the consumer (controller inline, desktop chrome in the message
// center) keyed on the action id rather than a server submitInteractive.
export function PlanImplementationSurface({
prompt,
variant = "full",
embedded = false,
edgeGlow = false,
isSubmitting,
isInteractionDisabled = false,
onSubmit,
labels
}: AgentInteractivePromptSurfaceProps & {
prompt: Extract<AgentConversationPromptVM, { kind: "plan-implementation" }>;
embedded?: boolean;
}) {
"use memo";
const [feedback, setFeedback] = useState("");
const trimmed = feedback.trim();
const continueLabel =
trimmed === ""
? labels.planImplementationSkip
: labels.planImplementationSend;
// Compact (message-center deck): only the "implement" decision is offered.
// Refining or staying in plan mode is deferred to the conversation, reachable
// via the card's "open conversation" jump.
const showFeedbackFooter = variant !== "compact";
const controlsDisabled = isSubmitting || isInteractionDisabled;
return (
<section className={interactivePromptClassName(embedded)}>
<div className={interactivePromptCardClassName(edgeGlow)}>
<div className={styles.interactivePromptLead}>
{stripPromptTitlePunctuation(labels.planImplementationLead)}
</div>
<div className={styles.interactivePromptOptions}>
<button
type="button"
className={styles.interactiveOptionButton}
data-testid="agent-plan-implementation-implement"
disabled={controlsDisabled}
onClick={() =>
onSubmit({
requestId: prompt.requestId,
action: PLAN_IMPLEMENTATION_ACTION_IMPLEMENT
})
}
>
<span className={styles.interactiveOptionContent}>
<span className={styles.interactiveOptionTitle}>
{labels.planImplementationConfirm}
</span>
</span>
</button>
</div>
{showFeedbackFooter ? (
<div className={styles.interactivePromptFooter}>
<textarea
value={feedback}
placeholder={labels.planImplementationFeedbackPlaceholder}
disabled={controlsDisabled}
className={styles.interactivePromptTextarea}
data-testid="agent-plan-implementation-feedback"
onChange={(event) => setFeedback(event.currentTarget.value)}
/>
<div className={styles.interactivePromptActions}>
<Button
type="button"
variant="secondary"
size="sm"
data-testid="agent-plan-implementation-continue"
disabled={controlsDisabled}
onClick={() =>
onSubmit({
requestId: prompt.requestId,
action:
trimmed === ""
? PLAN_IMPLEMENTATION_ACTION_SKIP
: PLAN_IMPLEMENTATION_ACTION_FEEDBACK,
payload: trimmed ? { text: trimmed } : undefined
})
}
>
{continueLabel}
</Button>
</div>
</div>
) : null}
</div>
</section>
);
}
@@ -0,0 +1,218 @@
import type { JSX } from "react";
import { canonicalInteractionKey } from "@tutti-os/agent-activity-core";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@tutti-os/ui-system";
import type {
AgentConversationPromptVM,
AgentInteractionResponseInput
} from "../contracts/agentConversationVM";
import { AgentAskUserPromptSurface } from "./AgentAskUserPromptSurface";
import {
ApprovalPromptSurface,
ExitPlanPromptSurface,
PlanImplementationSurface
} from "./AgentInteractiveDecisionPromptSurfaces";
/**
* Where the prompt is rendered, which sets its interaction budget:
* - "full" (conversation / composer): the user is focused here, so every action
* is shown — primary decisions plus rich follow-ups (feedback textareas,
* multi-step wizards, "stay in plan").
* - "compact" (message-center attention deck): a glanceable needs-attention card
* across many sessions. Ask-user prompts retain the full answer flow in a
* denser layout; other prompt kinds may intentionally limit secondary actions.
*/
export type AgentInteractivePromptVariant = "full" | "compact";
export interface AgentInteractivePromptConversationReturn {
continueAnswering: string;
returnToConversation: string;
}
export interface AgentInteractivePromptSurfaceProps {
prompt: AgentConversationPromptVM;
variant?: AgentInteractivePromptVariant;
/**
* Opt in only when the host Composer can answer this exact pending
* Interaction. The surface also fails closed for non-canonical and
* fixed-choice prompts.
*/
conversationReturn?: AgentInteractivePromptConversationReturn;
edgeGlow?: boolean;
keyboardShortcuts?: boolean;
isSubmitting: boolean;
isInteractionDisabled?: boolean;
interactionDisabledReason?: string | null;
onSubmit: (input: AgentInteractionResponseInput) => boolean | void;
labels: {
approvalLead: string;
fileChangeApprovalLead: string;
planLead: string;
planModes: Array<{ id: string; label: string; description: string }>;
stayInPlan: string;
sendFeedback: string;
feedbackPlaceholder: string;
previousQuestion: string;
nextQuestion: string;
submitAnswers: string;
answerPlaceholder: string;
waitingForAnswer: string;
planImplementationLead: string;
planImplementationConfirm: string;
planImplementationFeedbackPlaceholder: string;
planImplementationSend: string;
planImplementationSkip: string;
};
}
export function AgentInteractivePromptSurface({
prompt,
variant = "full",
conversationReturn,
edgeGlow = false,
embedded = false,
keyboardShortcuts = true,
isSubmitting,
isInteractionDisabled = false,
interactionDisabledReason = null,
onSubmit,
labels
}: AgentInteractivePromptSurfaceProps & {
embedded?: boolean;
}): JSX.Element | null {
"use memo";
const submitPrompt = (
input: AgentInteractionResponseInput
): boolean | void => {
const agentSessionId =
"agentSessionId" in prompt ? prompt.agentSessionId?.trim() : "";
const turnId = "turnId" in prompt ? prompt.turnId?.trim() : "";
return onSubmit({
...input,
...(agentSessionId && turnId ? { agentSessionId, turnId } : {}),
requestId: prompt.requestId
});
};
const isComposerAnswerableAskUser =
prompt.kind === "ask-user" &&
Boolean(prompt.agentSessionId?.trim()) &&
Boolean(prompt.turnId?.trim()) &&
prompt.questions.length > 0 &&
prompt.questions.every((question) => question.allowFreeText !== false);
const askUserConversationReturn = isComposerAnswerableAskUser
? conversationReturn
: undefined;
let promptSurface: JSX.Element;
if (prompt.kind === "approval") {
promptSurface = (
<ApprovalPromptSurface
prompt={prompt}
embedded={embedded}
edgeGlow={edgeGlow}
keyboardShortcuts={keyboardShortcuts}
isSubmitting={isSubmitting}
isInteractionDisabled={isInteractionDisabled}
onSubmit={submitPrompt}
labels={labels}
/>
);
} else if (prompt.kind === "exit-plan") {
promptSurface = (
<ExitPlanPromptSurface
prompt={prompt}
variant={variant}
embedded={embedded}
edgeGlow={edgeGlow}
isSubmitting={isSubmitting}
isInteractionDisabled={isInteractionDisabled}
onSubmit={submitPrompt}
labels={labels}
/>
);
} else if (prompt.kind === "plan-implementation") {
promptSurface = (
<PlanImplementationSurface
prompt={prompt}
variant={variant}
embedded={embedded}
edgeGlow={edgeGlow}
isSubmitting={isSubmitting}
isInteractionDisabled={isInteractionDisabled}
onSubmit={submitPrompt}
labels={labels}
/>
);
} else {
promptSurface = (
<AgentAskUserPromptSurface
key={canonicalInteractionKey(
prompt.agentSessionId ?? "",
prompt.turnId ?? "",
prompt.requestId
)}
prompt={prompt}
variant={variant}
conversationReturn={askUserConversationReturn}
embedded={embedded}
edgeGlow={edgeGlow}
isSubmitting={isSubmitting}
isInteractionDisabled={isInteractionDisabled}
onSubmit={submitPrompt}
labels={labels}
/>
);
}
const normalizedReason = interactionDisabledReason?.trim() ?? "";
if (!isInteractionDisabled || !normalizedReason) {
return promptSurface;
}
const hasPresentationNavigation =
prompt.kind === "ask-user" &&
variant === "full" &&
askUserConversationReturn !== undefined;
return (
<TooltipProvider delayDuration={120} skipDelayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div
aria-disabled={hasPresentationNavigation ? undefined : "true"}
aria-label={normalizedReason}
className={
hasPresentationNavigation
? "rounded-md outline-none"
: "cursor-not-allowed rounded-md outline-none"
}
data-agent-interaction-disabled="true"
role="group"
tabIndex={0}
>
{/* Without a presentation-only escape action, keep disabled
controls out of hit testing so the wrapper owns the tooltip. */}
<div
className={
hasPresentationNavigation ? undefined : "pointer-events-none"
}
>
{promptSurface}
</div>
</div>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-[min(360px,calc(100vw-32px))] whitespace-normal text-left [overflow-wrap:anywhere]"
>
{normalizedReason}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -0,0 +1,147 @@
import {
useCallback,
useEffect,
useState,
type JSX,
type ReactNode
} from "react";
import { CheckIcon, CopyIcon } from "@tutti-os/ui-system/icons";
import { Pencil } from "lucide-react";
import { formatAgentMessageTimestamp } from "../../../app/renderer/shell/utils/format";
import { translate } from "../../../i18n";
import styles from "../../../agent-gui/agentGuiNode/AgentGUIConversation.styles";
import type { AgentMessageRowVM } from "../contracts/agentMessageRowVM";
import { CanvasNodeGhostIconButton } from "../../../contexts/workspace/presentation/renderer/components/shared/CanvasNodeGhostIconButton";
const MESSAGE_COPY_FEEDBACK_MS = 1400;
export function AgentCopyableMessageGroup({
children,
copyText,
editAction,
footerAction,
occurredAtUnixMs,
onCopyMessageText,
speaker
}: {
children: ReactNode;
copyText: string | null;
editAction: {
disabled: boolean;
label: string;
onClick: () => void;
} | null;
footerAction?: ReactNode;
occurredAtUnixMs: number | null;
onCopyMessageText: (text: string) => Promise<boolean>;
speaker: AgentMessageRowVM["speaker"];
}): JSX.Element {
"use memo";
const timestamp = formatAgentMessageTimestamp(occurredAtUnixMs);
const hasFooter = Boolean(
timestamp || copyText || editAction || footerAction
);
return (
<div
className={styles.messageGroup}
data-agent-message-footer={hasFooter ? "true" : undefined}
data-agent-message-speaker={speaker}
>
{children}
{hasFooter ? (
<div className={styles.messageFooter}>
{timestamp ? (
<span className={styles.messageTimestamp}>{timestamp}</span>
) : null}
{copyText ? (
<AgentMessageCopyButton
copyText={copyText}
onCopyMessageText={onCopyMessageText}
/>
) : null}
{editAction ? (
<AgentMessageEditButton
disabled={editAction.disabled}
label={editAction.label}
onClick={editAction.onClick}
/>
) : null}
{footerAction}
</div>
) : null}
</div>
);
}
function AgentMessageEditButton({
disabled,
label,
onClick
}: {
disabled: boolean;
label: string;
onClick: () => void;
}): JSX.Element {
"use memo";
return (
<CanvasNodeGhostIconButton
className={styles.messageCopyButton}
aria-label={label}
disabled={disabled}
onClick={onClick}
>
<Pencil
size={14}
aria-hidden="true"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</CanvasNodeGhostIconButton>
);
}
function AgentMessageCopyButton({
copyText,
onCopyMessageText
}: {
copyText: string;
onCopyMessageText: (text: string) => Promise<boolean>;
}): JSX.Element {
"use memo";
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) {
return;
}
// timing: clear transient copy confirmation after the visible feedback window
const reset = window.setTimeout(() => {
setCopied(false);
}, MESSAGE_COPY_FEEDBACK_MS);
return () => window.clearTimeout(reset);
}, [copied]);
const handleClick = useCallback(async () => {
if (await onCopyMessageText(copyText)) {
setCopied(true);
}
}, [copyText, onCopyMessageText]);
const label = copied
? translate("agentHost.agentGui.messageCopied")
: translate("agentHost.agentGui.copyMessage");
return (
<CanvasNodeGhostIconButton
className={styles.messageCopyButton}
aria-label={label}
data-copied={copied ? "true" : "false"}
onClick={handleClick}
>
{copied ? (
<CheckIcon width={14} height={14} aria-hidden="true" />
) : (
<CopyIcon width={14} height={14} aria-hidden="true" />
)}
</CanvasNodeGhostIconButton>
);
}
@@ -0,0 +1,128 @@
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor
} from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AgentMessageBlock } from "./AgentMessageBlock";
vi.mock("./AgentToolGroupRow", () => ({
AgentToolGroupRow: () => null
}));
afterEach(cleanup);
describe("AgentMessageBlock edit retry", () => {
it("prefills the exact first text block and submits only edited text with turn identity", async () => {
const onSubmit = vi.fn().mockResolvedValue(true);
render(
<AgentMessageBlock
workspaceRoot="/workspace"
basePath="/workspace"
row={{
kind: "message",
id: "row-user",
turnId: "turn-latest",
speaker: "user",
rawFirstTextBlock: "raw original",
messages: [
{
kind: "message-content",
id: "message-user",
turnId: "turn-latest",
body: "rendered original",
presentationKind: "content",
contentKind: "text",
occurredAtUnixMs: 1
}
],
thinking: [],
occurredAtUnixMs: 1
}}
editRetry={{
pending: false,
labels: {
edit: "Edit message",
cancel: "Cancel",
submit: "Save and retry"
},
onSubmit
}}
thinkingLabel="Thinking"
/>
);
fireEvent.click(screen.getByRole("button", { name: "Edit message" }));
const editor = screen.getByRole("textbox", {
name: "Edit message"
}) as HTMLTextAreaElement;
expect(editor.value).toBe("raw original");
fireEvent.change(editor, { target: { value: "edited text" } });
fireEvent.click(screen.getByRole("button", { name: "Save and retry" }));
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith({
editedText: "edited text",
turnId: "turn-latest"
})
);
});
it("keeps the edited draft when the durable command reports failure", async () => {
const onSubmit = vi.fn().mockResolvedValue(false);
render(
<AgentMessageBlock
workspaceRoot="/workspace"
basePath="/workspace"
row={{
kind: "message",
id: "row-user",
turnId: "turn-latest",
speaker: "user",
rawFirstTextBlock: "raw original",
messages: [
{
kind: "message-content",
id: "message-user",
turnId: "turn-latest",
body: "rendered original",
presentationKind: "content",
contentKind: "text",
occurredAtUnixMs: 1
}
],
thinking: [],
occurredAtUnixMs: 1
}}
editRetry={{
pending: false,
labels: {
edit: "Edit message",
cancel: "Cancel",
submit: "Save and retry"
},
onSubmit
}}
thinkingLabel="Thinking"
/>
);
fireEvent.click(screen.getByRole("button", { name: "Edit message" }));
const editor = screen.getByRole("textbox", { name: "Edit message" });
fireEvent.change(editor, { target: { value: "keep this draft" } });
fireEvent.click(screen.getByRole("button", { name: "Save and retry" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
expect(
(
screen.getByRole("textbox", {
name: "Edit message"
}) as HTMLTextAreaElement
).value
).toBe("keep this draft");
});
});
@@ -0,0 +1,67 @@
import { fireEvent, render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AgentMessageBlock } from "./AgentMessageBlock";
import type { AgentMessageRowVM } from "../contracts/agentMessageRowVM";
const MENTION =
"[@修复登录问题](mention://workspace-issue/tutti-mode-plan-wf-1?topicId=topic-1&workspaceId=workspace-1)";
function buildPlanIssueLinkRow(): AgentMessageRowVM {
return {
kind: "message",
id: "row-1",
turnId: "plan-issue:tutti-mode-plan-wf-1",
speaker: "assistant",
occurredAtUnixMs: 0,
thinking: [],
messages: [
{
kind: "message-content",
id: "plan-issue:tutti-mode-plan-wf-1",
turnId: "plan-issue:tutti-mode-plan-wf-1",
body: MENTION,
presentationKind: "content",
contentKind: "tutti-plan-issue-link",
planIssueLink: {
issueId: "tutti-mode-plan-wf-1",
title: "修复登录问题",
mentionMarkdown: MENTION
},
occurredAtUnixMs: 0
}
]
};
}
describe("AgentMessageBlock plan-issue link", () => {
it("renders the plan Issue link as a friendly card with a clickable issue chip", () => {
const onLinkAction = vi.fn();
const { container, getByText, queryByText } = render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/"
row={buildPlanIssueLinkRow()}
onLinkAction={onLinkAction}
thinkingLabel="thinking"
/>
);
const card = container.querySelector(
'[data-testid="agent-tutti-plan-issue-link-card"]'
);
expect(card).toBeTruthy();
expect(card?.getAttribute("data-plan-issue-id")).toBe(
"tutti-mode-plan-wf-1"
);
// No raw markdown and no danger notice surface.
expect(queryByText(MENTION)).toBeNull();
expect(container.querySelector('[class*="on-danger"]')).toBeNull();
// The mention chip resolves the click through the standard workspace-issue
// link action pipeline.
fireEvent.click(getByText("修复登录问题"));
expect(onLinkAction).toHaveBeenCalledWith(
expect.objectContaining({ type: "open-workspace-issue" })
);
});
});
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { AgentMessageBlock } from "./AgentMessageBlock";
afterEach(cleanup);
describe("AgentMessageBlock selected text", () => {
it("renders a selected-text message as a compact reference chip", () => {
render(
<AgentMessageBlock
workspaceRoot="/workspace"
basePath="/workspace"
row={{
kind: "message",
id: "row-user",
turnId: "turn-1",
speaker: "user",
rawFirstTextBlock: null,
messages: [
{
kind: "message-content",
id: "message-user:selected-text",
turnId: "turn-1",
body: "",
presentationKind: "content",
contentKind: "selected-text",
selectedText: {
count: 1,
texts: ["Selected source text"]
},
occurredAtUnixMs: 1
}
],
thinking: [],
occurredAtUnixMs: 1
}}
thinkingLabel="Thinking"
/>
);
const chip = screen.getByTestId("agent-selected-text-chip");
expect(chip.getAttribute("data-selected-text-count")).toBe("1");
expect(chip).toHaveTextContent("1 selected file snippet");
expect(chip).toHaveClass("rounded-[10px]");
expect(chip).toHaveClass("bg-[var(--background-fronted)]");
expect(screen.queryByText("Selected source text")).toBeNull();
});
it("does not expose the selected-text chip as an editable message target", () => {
render(
<AgentMessageBlock
workspaceRoot="/workspace"
basePath="/workspace"
row={{
kind: "message",
id: "row-user",
turnId: "turn-1",
speaker: "user",
rawFirstTextBlock: "> Selected source text",
messages: [
{
kind: "message-content",
id: "message-user:selected-text",
turnId: "turn-1",
body: "",
presentationKind: "content",
contentKind: "selected-text",
selectedText: {
count: 1,
texts: ["Selected source text"]
},
occurredAtUnixMs: 1
}
],
thinking: [],
occurredAtUnixMs: 1
}}
editRetry={{
pending: false,
labels: {
edit: "Edit message",
cancel: "Cancel",
submit: "Save and retry"
},
onSubmit: async () => true
}}
thinkingLabel="Thinking"
/>
);
expect(screen.queryByRole("button", { name: "Edit message" })).toBeNull();
});
});
@@ -0,0 +1,472 @@
import { Fragment, useCallback, type JSX, type ReactNode } from "react";
import { toast } from "@tutti-os/ui-system";
import { AgentPlanCard } from "./AgentPlanCard";
import { AgentCollaborationRow } from "./AgentCollaborationRow";
import { useOptionalAgentHostApi } from "../../../agentActivityHost";
import {
AGENT_EXTERNAL_LINK_ACTION_SOURCE,
type WorkspaceLinkAction
} from "../../../contexts/workspace/presentation/renderer/actions/workspaceLinkActions";
import {
AgentMessageMarkdown,
type AgentMessageMarkdownWorkspaceAppIcon
} from "../../AgentMessageMarkdown";
import { AgentRichTextReadonly } from "../../AgentRichTextReadonly";
import { resolveAgentConversationLinkAction } from "../actions/agentConversationLinkActions";
import { parseMentionItemFromHref } from "../../../agent-gui/agentGuiNode/agentRichText/agentFileMentionExtension";
import { notifyComposerFileMentionBlocked } from "../../../agent-gui/agentGuiNode/composer/resolveComposerFileMentionLinkAction";
import type { AgentGUIProviderSkillOption } from "../../../agent-gui/agentGuiNode/model/agentGuiNodeTypes";
import type {
AgentMessageContentVM,
AgentMessageRowVM
} from "../contracts/agentMessageRowVM";
import type { AgentConversationParticipantPresentation } from "../contracts/agentConversationParticipantPresentation";
import { AgentConversationParticipantHeader } from "./AgentConversationParticipant";
import { AgentToolGroupRow } from "./AgentToolGroupRow";
import {
AgentVisibleErrorMessage,
recoverVisibleErrorFromMessage
} from "./AgentVisibleErrorMessage";
import { AgentThinkingDisclosure } from "./AgentThinkingDisclosure";
import { AgentTuttiModeCheckpointWakeCard } from "./AgentTuttiModeCheckpointWakeCard";
import { AgentTuttiPlanIssueLinkCard } from "./AgentTuttiPlanIssueLinkCard";
import { RawTimelineJsonDisclosure } from "./RawTimelineJsonDisclosure";
import styles from "../../../agent-gui/agentGuiNode/AgentGUIConversation.styles";
import { AgentUserImageGrid } from "./AgentMessageImages";
import { AgentSelectedTextChip } from "./AgentSelectedTextChip";
import {
AgentUserMessageEditor,
type AgentUserMessageEditRetryControl
} from "./AgentUserMessageEditRetry";
import { useAgentUserMessageEditRetry } from "./useAgentUserMessageEditRetry";
import { AgentCopyableMessageGroup } from "./AgentMessageActions";
import { AgentSystemNoticeMessage } from "./AgentSystemNoticeMessage";
const DEFAULT_TOOL_CALLS_LABEL = (count: number): string =>
`${count} tool calls`;
interface AgentMessageBlockProps {
workspaceRoot: string | null;
basePath: string;
row: AgentMessageRowVM;
editRetry?: AgentUserMessageEditRetryControl;
onLinkAction?: (action: WorkspaceLinkAction) => void;
thinkingLabel: string;
toolCallsLabel?: (count: number) => string;
onAuthLogin?: (provider?: string | null) => void;
// Routes a recovered environment-error CTA to the conversation provider.
provider?: string | null;
availableSkills?: readonly AgentGUIProviderSkillOption[];
workspaceAppIcons?: readonly AgentMessageMarkdownWorkspaceAppIcon[];
showRawTimelineJson?: boolean;
rawTimelineJsonLabel?: string;
participantPresentation?: AgentConversationParticipantPresentation;
showParticipantHeader?: boolean;
isActiveTurn?: boolean;
footerAction?: ReactNode;
}
export function AgentMessageBlock({
workspaceRoot,
basePath,
row,
editRetry,
onLinkAction,
thinkingLabel,
toolCallsLabel = DEFAULT_TOOL_CALLS_LABEL,
onAuthLogin,
provider,
availableSkills,
workspaceAppIcons,
showRawTimelineJson = false,
rawTimelineJsonLabel = "",
participantPresentation,
showParticipantHeader = true,
isActiveTurn = false,
footerAction
}: AgentMessageBlockProps): JSX.Element {
"use memo";
const agentHostApi = useOptionalAgentHostApi();
const isUser = row.speaker === "user";
const {
canEdit,
editableTargetMessageId,
editPending,
editState,
handleCancelEdit,
handleEditorKeyDown,
handleEditTextChange,
handleStartEdit,
handleSubmitEdit,
isEditing
} = useAgentUserMessageEditRetry({ editRetry, isUser, row });
const handleLinkClick = useCallback(
(href: string): void => {
const action = resolveAgentConversationLinkAction({
workspaceRoot,
basePath,
href,
source: "agent-markdown"
});
if (action) {
onLinkAction?.(action);
return;
}
// Sent transcripts can retain draft-only file hrefs; never fail silently.
const mention = parseMentionItemFromHref({ name: "", href });
if (
mention?.kind === "file" &&
mention.attachmentId &&
!mention.path.trim()
) {
notifyComposerFileMentionBlocked({
reason: "unavailable",
showError: (message) => {
if (agentHostApi?.toast?.error) {
agentHostApi.toast.error(message);
return;
}
toast.error(message);
},
showInfo: (message) => {
if (agentHostApi?.toast?.info) {
agentHostApi.toast.info(message);
return;
}
toast.error(message);
}
});
}
},
[agentHostApi, basePath, onLinkAction, workspaceRoot]
);
const handleExternalLinkClick = useCallback(
(href: string): void => {
const action = resolveAgentConversationLinkAction({
workspaceRoot,
basePath,
href,
source: AGENT_EXTERNAL_LINK_ACTION_SOURCE
});
if (action) {
onLinkAction?.(action);
}
},
[basePath, onLinkAction, workspaceRoot]
);
const handleCopyMessageText = useCallback(
async (text: string): Promise<boolean> => {
if (!text.trim()) {
return false;
}
try {
const hostWriteText = agentHostApi?.clipboard?.writeText;
if (typeof hostWriteText === "function") {
await hostWriteText(text);
return true;
}
if (
typeof navigator !== "undefined" &&
typeof navigator.clipboard?.writeText === "function"
) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
return false;
}
return false;
},
[agentHostApi]
);
const thinkingContent = !isUser
? row.thinking.map((thinking) => (
<AgentThinkingDisclosure
key={thinking.id}
thinking={thinking}
label={thinkingLabel}
onLinkClick={handleLinkClick}
showRawTimelineJson={showRawTimelineJson}
rawTimelineJsonLabel={rawTimelineJsonLabel}
/>
))
: null;
const leadingToolContent =
!isUser && row.leadingToolRows && row.leadingToolRows.length > 0
? row.leadingToolRows.map((toolRow) => (
<AgentToolGroupRow
key={toolRow.id}
row={toolRow}
label={toolCallsLabel}
thinkingLabel={thinkingLabel}
onLinkClick={handleLinkClick}
showRawTimelineJson={showRawTimelineJson}
rawTimelineJsonLabel={rawTimelineJsonLabel}
/>
))
: null;
const messageContent = row.messages.map((message, messageIndex) => {
const messageFooterAction =
messageIndex === row.messages.length - 1 ? footerAction : null;
const isEditTarget = canEdit && message.id === editableTargetMessageId;
const isEditingTarget = isEditing && isEditTarget;
const rawTimelineJson =
showRawTimelineJson &&
rawTimelineJsonLabel &&
(message.sourceTimelineItems?.length ?? 0) > 0 ? (
<RawTimelineJsonDisclosure
items={message.sourceTimelineItems}
label={rawTimelineJsonLabel}
/>
) : null;
// Recover structured errors, including Claude SDK's standalone login notice.
const recoveredError =
!isUser && !message.visibleError
? recoverVisibleErrorFromMessage(message, provider)
: null;
const renderedContent =
isUser &&
message.contentKind === "selected-text" &&
message.selectedText ? (
<AgentSelectedTextChip selectedText={message.selectedText} />
) : isUser && message.contentKind === "image-grid" ? (
<AgentUserImageGrid message={message} />
) : isUser &&
message.contentKind === "tutti-checkpoint-wake" &&
message.checkpointWake ? (
<AgentTuttiModeCheckpointWakeCard
checkpointWake={message.checkpointWake}
fullText={message.body}
/>
) : isUser ? (
<AgentRichTextReadonly
value={message.body}
documentCacheKey={message.id}
className={`workspace-agents-status-panel__detail-user-message ${styles.userMessageBubble}`}
editorClassName="text-[inherit]"
onLinkClick={handleLinkClick}
availableSkills={availableSkills}
workspaceAppIcons={workspaceAppIcons}
/>
) : message.contentKind === "tutti-plan-issue-link" &&
message.planIssueLink ? (
<AgentTuttiPlanIssueLinkCard
planIssueLink={message.planIssueLink}
workspaceRoot={workspaceRoot}
basePath={basePath}
onLinkAction={onLinkAction}
workspaceAppIcons={workspaceAppIcons}
/>
) : message.visibleError ? (
<AgentVisibleErrorMessage
message={message}
onAuthLogin={onAuthLogin}
onExternalLink={handleExternalLinkClick}
/>
) : recoveredError ? (
<AgentVisibleErrorMessage
message={recoveredError}
onAuthLogin={onAuthLogin}
onExternalLink={handleExternalLinkClick}
/>
) : message.systemNotice ? (
<AgentSystemNoticeMessage message={message} />
) : message.contentKind === "collaboration" && message.collaboration ? (
<AgentCollaborationRow
collaboration={message.collaboration}
workspaceRoot={workspaceRoot}
basePath={basePath}
onLinkAction={onLinkAction}
workspaceAppIcons={workspaceAppIcons}
/>
) : message.contentKind === "plan" ? (
<AgentPlanCardMessage
message={message}
workspaceRoot={workspaceRoot}
basePath={basePath}
onLinkAction={onLinkAction}
workspaceAppIcons={workspaceAppIcons}
/>
) : (
<AgentMessageMarkdown
content={message.body}
documentCacheKey={message.id}
className={styles.assistantMarkdown}
onLinkAction={onLinkAction}
workspaceLinkContext={{
workspaceRoot,
basePath,
source: "agent-markdown"
}}
workspaceAppIcons={workspaceAppIcons}
enableImageZoom
streaming={
isActiveTurn ||
message.statusKind === "working" ||
message.statusKind === "waiting"
}
/>
);
const editor =
isEditingTarget && editState && editRetry ? (
<AgentUserMessageEditor
value={editState.draft}
pending={editPending}
labels={editRetry.labels}
onChange={handleEditTextChange}
onCancel={handleCancelEdit}
onSubmit={handleSubmitEdit}
onKeyDown={handleEditorKeyDown}
/>
) : null;
const content =
editor && message.contentKind !== "image-grid" ? (
editor
) : editor ? (
<>
{renderedContent}
{editor}
</>
) : (
renderedContent
);
const editAction =
isEditTarget && !isEditing && editRetry
? {
disabled: editPending,
label: editRetry.labels.edit,
onClick: handleStartEdit
}
: null;
if (rawTimelineJson) {
return (
<AgentCopyableMessageGroup
key={message.id}
copyText={message.copyText ?? null}
editAction={editAction}
occurredAtUnixMs={message.occurredAtUnixMs}
speaker={row.speaker}
onCopyMessageText={handleCopyMessageText}
footerAction={messageFooterAction}
>
{content}
{rawTimelineJson}
</AgentCopyableMessageGroup>
);
}
const copyText = message.copyText ?? null;
if (copyText || editAction || isEditingTarget) {
return (
<AgentCopyableMessageGroup
key={message.id}
copyText={copyText}
editAction={editAction}
occurredAtUnixMs={message.occurredAtUnixMs}
speaker={row.speaker}
onCopyMessageText={handleCopyMessageText}
footerAction={messageFooterAction}
>
{content}
</AgentCopyableMessageGroup>
);
}
if (messageFooterAction) {
return (
<AgentCopyableMessageGroup
key={message.id}
copyText={null}
editAction={null}
occurredAtUnixMs={message.occurredAtUnixMs}
speaker={row.speaker}
onCopyMessageText={handleCopyMessageText}
footerAction={messageFooterAction}
>
{content}
</AgentCopyableMessageGroup>
);
}
return <Fragment key={message.id}>{content}</Fragment>;
});
const enabledParticipantPresentation =
participantPresentation?.enabled === true ? participantPresentation : null;
const showParticipant =
enabledParticipantPresentation !== null &&
showParticipantHeader &&
row.messages.length > 0;
return (
<div
className={isUser ? styles.userMessageFlow : styles.assistantMessageFlow}
data-agent-message-flow-thinking-first={
!isUser && row.thinking.length > 0 ? "true" : undefined
}
data-agent-message-flow-thinking-last={
!isUser && row.thinking.length > 0 && row.messages.length === 0
? "true"
: undefined
}
data-agent-message-flow-participant={showParticipant ? "true" : undefined}
>
{showParticipant ? (
<AgentConversationParticipantHeader
presentation={enabledParticipantPresentation}
speaker={row.speaker}
/>
) : null}
{thinkingContent}
{leadingToolContent}
{showParticipant ? (
<div
className={styles.participantMessageLayout}
data-agent-message-speaker={row.speaker}
>
<div className={styles.participantMessageContent}>
{messageContent}
</div>
</div>
) : (
messageContent
)}
</div>
);
}
// Codex plan-mode proposals render as a framed card (mirrors the codex TUI
// treating the plan item as a distinct artifact rather than chat text).
function AgentPlanCardMessage({
message,
workspaceRoot,
basePath,
onLinkAction,
workspaceAppIcons
}: {
message: AgentMessageContentVM;
workspaceRoot: string | null;
basePath: string;
onLinkAction?: (action: WorkspaceLinkAction) => void;
workspaceAppIcons?: readonly AgentMessageMarkdownWorkspaceAppIcon[];
}): JSX.Element {
"use memo";
return (
<AgentPlanCard copyText={message.body}>
<AgentMessageMarkdown
content={message.body}
className={styles.assistantMarkdown}
onLinkAction={onLinkAction}
workspaceLinkContext={{
workspaceRoot,
basePath,
source: "agent-markdown"
}}
workspaceAppIcons={workspaceAppIcons}
enableImageZoom
/>
</AgentPlanCard>
);
}
@@ -0,0 +1,561 @@
import { fireEvent, render } from "@testing-library/react";
import type { ComponentProps } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AgentMessageBlock } from "./AgentMessageBlock";
import type {
AgentMessageContentVM,
AgentMessageRowVM
} from "../contracts/agentMessageRowVM";
import { AgentEnvPanelActionProvider } from "../../agentEnv";
import { AgentVisibleErrorPresentationProvider } from "../../visibleError/AgentVisibleErrorPresentationContext";
import type {
AgentVisibleErrorOverrides,
AgentVisibleErrorPresentationScope
} from "../../agentEnv/agentErrorPresentation";
function buildRow(
visibleError: AgentMessageContentVM["visibleError"],
body = ""
): AgentMessageRowVM {
return {
kind: "message",
id: "row-1",
turnId: "turn-1",
speaker: "assistant",
occurredAtUnixMs: 0,
thinking: [],
messages: [
{
kind: "message-content",
id: "msg-1",
turnId: "turn-1",
body,
presentationKind: "content",
occurredAtUnixMs: 0,
visibleError
}
]
};
}
function renderBlock(
row: AgentMessageRowVM,
provider?: string,
onLinkAction?: ComponentProps<typeof AgentMessageBlock>["onLinkAction"],
visibleErrorPresentationOverrides?: AgentVisibleErrorOverrides | null,
visibleErrorPresentationScope?: AgentVisibleErrorPresentationScope
) {
const onOpenAgentEnvPanel = vi.fn();
return {
...render(
<AgentEnvPanelActionProvider openPanel={onOpenAgentEnvPanel}>
<AgentVisibleErrorPresentationProvider
scope={visibleErrorPresentationScope}
value={visibleErrorPresentationOverrides}
>
<AgentMessageBlock
workspaceRoot={null}
basePath="/"
row={row}
provider={provider}
onLinkAction={onLinkAction}
thinkingLabel="thinking"
/>
</AgentVisibleErrorPresentationProvider>
</AgentEnvPanelActionProvider>
),
onOpenAgentEnvPanel
};
}
function buildFailedTextRow(body: string): AgentMessageRowVM {
return {
kind: "message",
id: "row-1",
turnId: "turn-1",
speaker: "assistant",
occurredAtUnixMs: 0,
thinking: [],
messages: [
{
kind: "message-content",
id: "msg-1",
turnId: "turn-1",
body,
presentationKind: "content",
statusKind: "failed",
occurredAtUnixMs: 0,
visibleError: null
}
]
};
}
function buildCompletedTextRow(body: string): AgentMessageRowVM {
const row = buildFailedTextRow(body);
const message = row.messages[0];
if (message) {
message.statusKind = "completed";
}
return row;
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("AgentVisibleErrorMessage", () => {
it("routes an env-fixable run failure to the matching wizard step", () => {
const { getByText, getAllByRole, onOpenAgentEnvPanel } = renderBlock(
buildRow(
{
// The real code a missing CLI surfaces as at run time.
code: "cli_not_found",
phase: "start",
provider: "codex",
detail: "spawn codex ENOENT",
retryable: false
},
// The raw body must NOT be surfaced as the card title.
"codex exited: spawn codex ENOENT"
)
);
expect(
getByText(
"Codex CLI wasn't found, so it couldn't run. Set it up to continue."
)
).toBeTruthy();
expect(() => getByText("codex exited: spawn codex ENOENT")).toThrow();
const action = getAllByRole("button").find(
(button) => button.textContent === "Connect"
);
expect(action).toBeTruthy();
fireEvent.click(action as HTMLButtonElement);
expect(onOpenAgentEnvPanel).toHaveBeenCalledWith({
provider: "codex",
focus: "install"
});
});
it("offers a self-detect escape hatch for ambiguous hard failures", () => {
const { getAllByRole, queryByText, onOpenAgentEnvPanel } = renderBlock(
buildRow(
{
code: "process_exited",
phase: "turn",
provider: "codex",
detail: "exited with code 1",
retryable: false
},
"provider process exited with secret diagnostics"
)
);
expect(
queryByText("provider process exited with secret diagnostics")
).toBeNull();
const action = getAllByRole("button").find(
(button) => button.textContent === "Open setup"
);
expect(action).toBeTruthy();
fireEvent.click(action as HTMLButtonElement);
expect(onOpenAgentEnvPanel).toHaveBeenCalledWith({
provider: "codex",
focus: "detect"
});
});
it("keeps the remediation action when the provider is unavailable", () => {
const { getAllByRole, onOpenAgentEnvPanel } = renderBlock(
buildRow({
code: "cli_not_found",
phase: "start",
provider: null,
detail: "spawn failed",
retryable: false
})
);
const action = getAllByRole("button").find(
(button) => button.textContent === "Connect"
);
expect(action).toBeTruthy();
fireEvent.click(action as HTMLButtonElement);
expect(onOpenAgentEnvPanel).toHaveBeenCalledWith({
provider: null,
focus: "install"
});
});
it("does not render raw provider payloads in the product card", () => {
const { queryByText } = renderBlock(
buildRow({
code: "cli_not_found",
phase: "start",
provider: "codex",
detail: "spawn codex ENOENT",
retryable: false
})
);
expect(queryByText("spawn codex ENOENT")).toBeNull();
expect(queryByText("Raw error")).toBeNull();
});
it("reveals shared-agent raw details only after the disclosure is opened", () => {
const { getByRole, getByText } = renderBlock(
buildRow({
code: "provider_error",
phase: "turn",
provider: "claude-code",
detail: "provider response: account is not authenticated",
detailAvailable: true,
retryable: false
})
);
const disclosure = getByRole("button", { name: "Raw error" });
expect(disclosure).toHaveAttribute("aria-expanded", "false");
fireEvent.click(disclosure);
expect(disclosure).toHaveAttribute("aria-expanded", "true");
expect(
getByText("provider response: account is not authenticated")
).toBeTruthy();
});
it("shows a sanitized provider error directly without rewriting it", () => {
const { getByText, queryByRole } = renderBlock(
buildRow({
code: "provider_unavailable",
phase: "turn",
provider: "claude-code",
origin: "provider",
detail: "relay returned HTTP 503: upstream overloaded",
detailAvailable: true,
retryable: true
})
);
expect(
getByText("relay returned HTTP 503: upstream overloaded")
).toBeTruthy();
expect(queryByRole("button", { name: "Raw error" })).toBeNull();
});
it("does not offer provider login for a relay-scoped 401", () => {
const { getByText, queryByText } = renderBlock(
buildRow({
code: "provider_error",
phase: "turn",
provider: "claude-code",
origin: "provider",
detail: "relay rejected request: HTTP 401",
retryable: false
})
);
expect(getByText("relay rejected request: HTTP 401")).toBeTruthy();
expect(queryByText("Sign in")).toBeNull();
});
it("shows accurate copy but NO wizard CTA for transient/server-side failures", () => {
const { getByText, queryByText } = renderBlock(
buildRow({
code: "request_timed_out",
phase: "turn",
provider: "codex",
detail: null,
retryable: true
})
);
expect(getByText("Codex request timed out")).toBeTruthy();
// No env-panel call-to-action — the wizard cannot fix a transient timeout.
expect(queryByText("Set up")).toBeNull();
expect(queryByText("Open setup")).toBeNull();
expect(queryByText("Sign in")).toBeNull();
});
it("fails closed without Host Commerce context", () => {
const onLinkAction = vi.fn();
const { getByText, queryByText } = renderBlock(
buildRow({
code: "insufficient_credits",
phase: "turn",
provider: "tutti-agent",
detail:
"unexpected status 402 Payment Required: pre-deduct credits failed",
retryable: false
}),
"tutti-agent",
onLinkAction
);
expect(
getByText("Tutti has insufficient credits or account balance to continue")
).toBeTruthy();
expect(queryByText("Open setup")).toBeNull();
expect(queryByText("View credit options")).toBeNull();
expect(onLinkAction).not.toHaveBeenCalled();
});
it("renders a generic Host error override without exposing Commerce state", () => {
const onLinkAction = vi.fn();
const { getByText, queryByText } = renderBlock(
buildRow({
code: "insufficient_credits",
phase: "turn",
provider: "tutti-agent",
detail: "private provider billing payload",
retryable: false
}),
"tutti-agent",
onLinkAction,
{
insufficient_credits: {
message: "Recharge credits to continue",
providers: ["tutti-agent"],
action: {
label: "Recharge credits",
url: "https://example.test/credits"
}
}
}
);
expect(getByText("Recharge credits to continue")).toBeTruthy();
expect(queryByText("private provider billing payload")).toBeNull();
fireEvent.click(getByText("Recharge credits"));
expect(onLinkAction).toHaveBeenCalledWith(
expect.objectContaining({
source: "agent-external-action",
type: "open-url",
url: "https://example.test/credits"
})
);
});
it("shows sharer guidance instead of local authentication recovery", () => {
const { getByRole, getByText, queryByText, onOpenAgentEnvPanel } =
renderBlock(
buildRow({
code: "auth_required",
phase: "turn",
provider: "codex",
detail: "owner diagnostic: local Codex login is required",
detailAvailable: true,
retryable: false
}),
"codex",
undefined,
undefined,
"shared_caller"
);
expect(
getByText("Codex needs authentication or configuration")
).toBeTruthy();
expect(getByText("auth_required")).toBeTruthy();
expect(
getByText("Contact the person who shared this Agent, then try again.")
).toBeTruthy();
expect(queryByText("Sign in")).toBeNull();
expect(onOpenAgentEnvPanel).not.toHaveBeenCalled();
fireEvent.click(getByRole("button", { name: "Raw error" }));
expect(
getByText("owner diagnostic: local Codex login is required")
).toBeTruthy();
});
it("does not expose a caller Commerce action for an Owner credit failure", () => {
const onLinkAction = vi.fn();
const { getByText, queryByText } = renderBlock(
buildRow({
code: "insufficient_credits",
phase: "turn",
provider: "tutti-agent",
detail: "owner balance is insufficient",
retryable: false
}),
"tutti-agent",
onLinkAction,
{
insufficient_credits: {
message: "Recharge credits to continue",
providers: ["tutti-agent"],
action: {
label: "Recharge credits",
url: "https://example.test/credits"
}
}
},
"shared_caller"
);
expect(
getByText("Tutti has insufficient credits or account balance to continue")
).toBeTruthy();
expect(
getByText("Contact the person who shared this Agent, then try again.")
).toBeTruthy();
expect(queryByText("Recharge credits to continue")).toBeNull();
expect(queryByText("Recharge credits")).toBeNull();
expect(onLinkAction).not.toHaveBeenCalled();
});
it("shows the stable shared-caller code when provider detail is absent or unknown", () => {
const { getByText, queryByText } = renderBlock(
buildRow({
code: "future_owner_failure",
phase: "provider_start",
provider: "codex",
detail: null,
detailAvailable: false,
retryable: null
}),
"codex",
undefined,
undefined,
"shared_caller"
);
expect(getByText("future_owner_failure")).toBeTruthy();
expect(queryByText("invocation-1")).toBeNull();
expect(queryByText("correlation-1")).toBeNull();
expect(queryByText("owner-device-1")).toBeNull();
});
it("preserves neutral transient copy while suppressing local setup actions", () => {
const { getByText, queryByText } = renderBlock(
buildRow({
code: "network_error",
phase: "turn",
provider: "codex",
detail: null,
retryable: true
}),
"codex",
undefined,
undefined,
"shared_caller"
);
expect(
getByText("Codex couldn't reach the network to complete this request.")
).toBeTruthy();
expect(
queryByText("Contact the person who shared this Agent, then try again.")
).toBeNull();
expect(queryByText("Check network")).toBeNull();
});
it("does not apply a Tutti Commerce override to another provider", () => {
const { getByText, queryByText } = renderBlock(
buildRow({
code: "insufficient_credits",
phase: "turn",
provider: "codex",
detail: "insufficient credits",
retryable: false
}),
"codex",
vi.fn(),
{
insufficient_credits: {
message: "Recharge Tutti credits",
providers: ["tutti-agent"],
action: {
label: "Recharge",
url: "https://example.test/credits"
}
}
}
);
expect(
getByText("Codex has insufficient credits or account balance to continue")
).toBeTruthy();
expect(queryByText("Recharge Tutti credits")).toBeNull();
expect(queryByText("Recharge")).toBeNull();
});
it("shows Cursor plan-limit cards as a calm warning status, not a danger alert", () => {
const { getByText, getByRole, queryByText } = renderBlock(
buildRow({
code: "quota_or_rate_limit",
phase: "turn",
provider: "cursor",
detail: "Upgrade your plan to continue",
retryable: false
})
);
expect(
getByText(
"Cursor request failed because a quota or rate limit was reached"
)
).toBeTruthy();
expect(getByRole("status")).toBeTruthy();
expect(queryByText("Open setup")).toBeNull();
expect(queryByText("Sign in")).toBeNull();
});
it("recovers a failed plain auth message into the wizard card (Claude 401)", () => {
const { getByText, getAllByRole, onOpenAgentEnvPanel } = renderBlock(
buildFailedTextRow(
"Failed to authenticate. API Error: 401 Invalid authentication credentials"
),
"claude-code"
);
// Rendered as the structured card (not dead red text), routing to the wizard.
expect(
getByText("Claude Code needs authentication or configuration")
).toBeTruthy();
const action = getAllByRole("button").find(
(button) => button.textContent === "Sign in"
);
expect(action).toBeTruthy();
fireEvent.click(action as HTMLButtonElement);
expect(onOpenAgentEnvPanel).toHaveBeenCalledWith({
provider: "claude-code",
focus: "auth"
});
});
it("recovers a failed Claude 522 response into a timeout card", () => {
const rawError =
'API Error: 522 {"title":"Error 522: Connection timed out"}';
const { getByText, queryByText } = renderBlock(
buildFailedTextRow(rawError),
"claude-code"
);
expect(getByText("Claude Code request timed out")).toBeTruthy();
expect(queryByText(rawError)).toBeNull();
});
it("recovers Claude SDK's completed login notice into the wizard card", () => {
const { getByText, queryByText } = renderBlock(
buildCompletedTextRow("Not logged in · Please run /login"),
"claude-code"
);
expect(
getByText("Claude Code needs authentication or configuration")
).toBeTruthy();
expect(getByText("Sign in")).toBeTruthy();
expect(queryByText("Not logged in · Please run /login")).toBeNull();
});
it("leaves a non-env failed message as plain text (no card)", () => {
const { queryByText } = renderBlock(
buildFailedTextRow("rate limit exceeded, try again later"),
"codex"
);
expect(queryByText("Sign in")).toBeNull();
});
});
@@ -0,0 +1,53 @@
import { useState, type JSX } from "react";
import { ChevronRight } from "lucide-react";
import { translate } from "../../../i18n/index";
import { CollapsibleReveal } from "./CollapsibleReveal";
export function AgentMessageDetailsDisclosure({
detail,
className = "",
label,
tone = "danger"
}: {
detail: string;
className?: string;
label?: string;
tone?: "danger" | "muted";
}): JSX.Element {
"use memo";
const [expanded, setExpanded] = useState(false);
const isMuted = tone === "muted";
return (
<div
className={`${className} text-[11px] ${isMuted ? "text-[var(--text-secondary)]" : "text-[var(--state-danger)]"}`}
>
<button
type="button"
className={`inline-flex w-fit max-w-full min-w-0 cursor-pointer select-none items-center gap-1.5 border-0 bg-transparent p-0 text-left font-[inherit] text-[inherit] transition-colors duration-150 ${isMuted ? "hover:text-[var(--text-primary)]" : "hover:text-[var(--state-danger-hover)]"}`}
aria-expanded={expanded}
onClick={() => setExpanded((value) => !value)}
>
{label ?? translate("agentHost.agentGui.visibleErrorDetails")}
<ChevronRight
size={12}
strokeWidth={2.2}
aria-hidden="true"
className={`shrink-0 ${isMuted ? "text-[var(--text-secondary)]" : "text-[var(--state-danger)]"}`}
style={{
transform: expanded ? "rotate(90deg)" : "rotate(0deg)",
transformOrigin: "center",
transition: "transform 200ms cubic-bezier(0.22, 1.18, 0.36, 1)",
willChange: "transform"
}}
/>
</button>
<CollapsibleReveal expanded={expanded} preMountOnIdle>
<pre
className={`mt-2 max-h-[220px] overflow-auto whitespace-pre-wrap break-words rounded-[6px] px-3 py-2 font-[var(--tsh-font-mono)] text-[11px] leading-5 ${isMuted ? "bg-[var(--transparency-block)] text-[var(--text-secondary)]" : "bg-[var(--on-danger)] text-[var(--state-danger)]"}`}
>
{detail}
</pre>
</CollapsibleReveal>
</div>
);
}
@@ -0,0 +1,536 @@
import { Button } from "@tutti-os/ui-system";
import {
AlertCircle,
LoaderCircle,
RotateCcw,
type LucideIcon
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type JSX } from "react";
import {
useOptionalAgentGUIRuntime,
type AgentGUIRuntime
} from "../../../agentActivityRuntime";
import { ZoomableImage } from "../../../app/renderer/components/ZoomableImage";
import { useTranslation } from "../../../i18n/index";
import styles from "../../../agent-gui/agentGuiNode/AgentGUIConversation.styles";
import type {
AgentMessageContentVM,
AgentMessageImageVM
} from "../contracts/agentMessageRowVM";
type ImageFailureKind = "load" | "read" | "unavailable";
type ImageFailureUnavailableReason = "source_vm";
type ImageFailureSource =
| {
kind: "resolved";
src: string;
}
| {
agentSessionId: string;
attachmentId: string;
kind: "locator";
path: string;
readerAvailable: boolean;
unavailableReason?: ImageFailureUnavailableReason;
workspaceId: string;
};
type ImageFailureReporter = (
image: AgentMessageImageVM,
kind: ImageFailureKind,
attempt: number
) => void;
export function AgentUserImageGrid({
message
}: {
message: AgentMessageContentVM;
}): JSX.Element {
"use memo";
const images = message.images ?? [];
const runtime = useOptionalAgentGUIRuntime();
const reportFailure = useCallback(
(image: AgentMessageImageVM, kind: ImageFailureKind, attempt: number) => {
reportImageLoadFailure(runtime, image, kind, attempt);
},
[runtime]
);
const { failedSources, loadingIds, retryCounts, sources, markFailed, retry } =
useAgentMessageImageSources(images, reportFailure);
const { t } = useTranslation();
const columnCount = Math.min(Math.max(images.length, 1), 4);
const thumbnailWidth = images.length === 1 ? "160px" : "80px";
return (
<div
className={styles.userImageGrid}
style={{
gridTemplateColumns: `repeat(${columnCount}, ${thumbnailWidth})`
}}
>
{images.map((image) => {
const src = sources.get(image.id) ?? imageSourceUrl(image);
const failureSource = imageFailureSource(image, src, runtime);
const failedSource = failedSources.get(image.id);
const displayedFailureSource = failedSource ?? failureSource;
const failed = isMatchingImageFailure(failedSource, failureSource);
const canRetry = canRetryImageFailure(displayedFailureSource);
const attempt = retryCounts.get(image.id) ?? 0;
return (
<AgentUserImageTile
key={image.id}
canRetry={canRetry}
image={image}
src={src}
failed={failed}
sourceLoading={!src && loadingIds.has(image.id)}
attempt={attempt}
failureLabel={t(imageFailureLabelKey(displayedFailureSource))}
retryLabel={t("agentHost.agentGui.retryImage")}
onFailed={() => {
markFailed(image.id, failureSource);
reportFailure(image, "load", attempt);
}}
onRetry={() => retry(image.id)}
/>
);
})}
</div>
);
}
function AgentUserImageTile({
attempt,
canRetry,
failed,
failureLabel,
image,
onFailed,
onRetry,
retryLabel,
sourceLoading,
src
}: {
attempt: number;
canRetry: boolean;
failed: boolean;
failureLabel: string;
image: AgentMessageImageVM;
onFailed: () => void;
onRetry: () => void;
retryLabel: string;
sourceLoading: boolean;
src: string | null;
}): JSX.Element {
const [loadedImage, setLoadedImage] = useState<{
attempt: number;
src: string;
} | null>(null);
const browserLoaded = Boolean(
src && loadedImage?.attempt === attempt && loadedImage.src === src
);
const showingFailure = failed || (!src && !sourceLoading);
const loading = !failed && (src ? !browserLoaded : sourceLoading);
const state = showingFailure ? "failed" : loading ? "loading" : "loaded";
return (
<div
aria-busy={loading || undefined}
className={styles.userImageThumbnail}
data-image-state={state}
>
{showingFailure ? (
<ImageLoadFailurePlaceholder
canRetry={canRetry}
label={failureLabel}
retryLabel={retryLabel}
icon={AlertCircle}
onRetry={onRetry}
/>
) : src ? (
<>
<ZoomableImage
key={`${image.id}:${attempt}:${src}`}
src={src}
alt={image.name?.trim() || "image"}
className="block h-full w-full rounded-[7px] object-contain"
draggable={false}
downloadName={image.name?.trim() || "image.png"}
onLoad={() => {
setLoadedImage((current) =>
current?.attempt === attempt && current.src === src
? current
: { attempt, src }
);
}}
onError={onFailed}
/>
{loading ? <ImageLoadingPlaceholder /> : null}
</>
) : (
<ImageLoadingPlaceholder />
)}
</div>
);
}
function useAgentMessageImageSources(
images: readonly AgentMessageImageVM[],
reportFailure: ImageFailureReporter
): {
failedSources: ReadonlyMap<string, ImageFailureSource>;
loadingIds: ReadonlySet<string>;
retryCounts: ReadonlyMap<string, number>;
sources: ReadonlyMap<string, string>;
markFailed: (imageId: string, source: ImageFailureSource) => void;
retry: (imageId: string) => void;
} {
const runtime = useOptionalAgentGUIRuntime();
const [failedSources, setFailedSources] = useState<
Map<string, ImageFailureSource>
>(() => new Map());
const [sources, setSources] = useState<Map<string, string>>(() => new Map());
const [loadingIds, setLoadingIds] = useState<Set<string>>(() => new Set());
const [retryCounts, setRetryCounts] = useState<Map<string, number>>(
() => new Map()
);
const markFailed = useCallback(
(imageId: string, source: ImageFailureSource): void => {
setFailedSources((current) => {
if (isMatchingImageFailure(current.get(imageId), source)) {
return current;
}
const next = new Map(current);
next.set(imageId, source);
return next;
});
setLoadingIds((current) => {
if (!current.has(imageId)) return current;
const next = new Set(current);
next.delete(imageId);
return next;
});
},
[]
);
const retry = useCallback((imageId: string): void => {
setFailedSources((current) => {
if (!current.has(imageId)) return current;
const next = new Map(current);
next.delete(imageId);
return next;
});
setRetryCounts((current) => {
const next = new Map(current);
next.set(imageId, (next.get(imageId) ?? 0) + 1);
return next;
});
}, []);
const missingImages = useMemo(
() =>
images.filter(
(image) =>
!imageSourceUrl(image) &&
!sources.has(image.id) &&
!isMatchingImageFailure(
failedSources.get(image.id),
imageFailureSource(image, null, runtime)
) &&
image.workspaceId &&
image.agentSessionId &&
(image.attachmentId
? runtime?.readSessionAttachment
: runtime?.readPromptAsset) &&
(image.attachmentId || image.path)
),
[failedSources, images, runtime, sources]
);
const unavailableImages = useMemo(
() =>
images.filter(
(image) =>
!imageSourceUrl(image) &&
!sources.has(image.id) &&
!isMatchingImageFailure(
failedSources.get(image.id),
imageFailureSource(image, null, runtime)
) &&
(!image.workspaceId ||
!image.agentSessionId ||
!(image.attachmentId || image.path) ||
(image.attachmentId
? !runtime?.readSessionAttachment
: !runtime?.readPromptAsset))
),
[failedSources, images, runtime, sources]
);
const visibleLoadingIds = new Set(loadingIds);
for (const image of missingImages) visibleLoadingIds.add(image.id);
useEffect(() => {
let canceled = false;
if (
runtime &&
(runtime.readSessionAttachment || runtime.readPromptAsset) &&
missingImages.length > 0
) {
for (const image of missingImages) {
const readImage = image.attachmentId
? runtime.readSessionAttachment?.({
workspaceId: image.workspaceId ?? "",
agentSessionId: image.agentSessionId,
attachmentId: image.attachmentId ?? ""
})
: runtime.readPromptAsset?.({
workspaceId: image.workspaceId ?? "",
agentSessionId: image.agentSessionId,
mimeType: image.mimeType,
name: image.name,
path: image.path
});
if (!readImage) continue;
setLoadingIds((current) => new Set(current).add(image.id));
const attempt = retryCounts.get(image.id) ?? 0;
void readImage
.then((attachment) => {
if (canceled) return;
setSources((current) => {
const next = new Map(current);
next.set(
image.id,
`data:${attachment.mimeType};base64,${attachment.data}`
);
return next;
});
})
.catch((error: unknown) => {
if (canceled) return;
const unavailableReason = isSourceVMUnavailableError(error)
? "source_vm"
: undefined;
markFailed(
image.id,
imageFailureSource(image, null, runtime, unavailableReason)
);
reportFailure(
image,
unavailableReason ? "unavailable" : "read",
attempt
);
})
.finally(() => {
if (canceled) return;
setLoadingIds((current) => {
const next = new Set(current);
next.delete(image.id);
return next;
});
});
}
}
for (const image of unavailableImages) {
const attempt = retryCounts.get(image.id) ?? 0;
markFailed(image.id, imageFailureSource(image, null, runtime));
reportFailure(image, "unavailable", attempt);
}
return () => {
canceled = true;
};
}, [
markFailed,
missingImages,
reportFailure,
retryCounts,
runtime,
unavailableImages
]);
return {
failedSources,
loadingIds: visibleLoadingIds,
retryCounts,
sources,
markFailed,
retry
};
}
function ImageLoadingPlaceholder(): JSX.Element {
return (
<div
className="absolute inset-0 flex items-center justify-center bg-[color-mix(in_srgb,var(--text-primary)_6%,transparent)]"
data-testid="agent-gui-message-image-loading"
>
<LoaderCircle
aria-hidden="true"
className="size-5 animate-spin text-[color-mix(in_srgb,var(--text-primary)_45%,transparent)]"
strokeWidth={2}
/>
</div>
);
}
function ImageLoadFailurePlaceholder({
canRetry,
icon: Icon,
label,
onRetry,
retryLabel
}: {
canRetry: boolean;
icon: LucideIcon;
label: string;
onRetry: () => void;
retryLabel: string;
}): JSX.Element {
return (
<div
className="flex h-full w-full flex-col items-center justify-center gap-1 bg-[var(--transparency-block)] px-1 text-[var(--text-secondary)]"
data-testid="agent-gui-message-image-failed"
role="status"
aria-label={label}
>
<Icon aria-hidden="true" className="size-4" />
<span className="max-w-full truncate text-xs" title={label}>
{label}
</span>
{canRetry ? (
<Button type="button" variant="ghost" size="xs" onClick={onRetry}>
<RotateCcw aria-hidden="true" />
{retryLabel}
</Button>
) : null}
</div>
);
}
function reportImageLoadFailure(
runtime: AgentGUIRuntime | null,
image: AgentMessageImageVM,
kind: ImageFailureKind,
attempt: number
): void {
const reportDiagnostic = runtime?.reportDiagnostic;
if (!reportDiagnostic) return;
try {
void Promise.resolve(
reportDiagnostic.call(runtime, {
event: "agent.gui.conversation.image_load_failed",
level: "warn",
source: "agent-gui",
workspaceId: image.workspaceId ?? null,
details: {
imageId: image.id,
attempt,
failureKind: kind,
hasUrl: Boolean(image.url?.trim()),
hasData: Boolean(image.data?.trim()),
hasAttachmentId: Boolean(image.attachmentId?.trim()),
hasPath: Boolean(image.path?.trim())
}
})
).catch((error: unknown) => {
reportImageLoadDiagnosticFailure(image, error);
});
} catch (error) {
reportImageLoadDiagnosticFailure(image, error);
}
}
function reportImageLoadDiagnosticFailure(
image: AgentMessageImageVM,
error: unknown
): void {
console.warn(
"[agent-gui]",
JSON.stringify({
event: "agent.gui.conversation.image_diagnostic_failed",
level: "warn",
source: "agent-gui",
workspaceId: image.workspaceId ?? null,
details: {
diagnosticEvent: "agent.gui.conversation.image_load_failed",
error: error instanceof Error ? error.message : String(error)
}
})
);
}
function imageDataUrl(image: AgentMessageImageVM): string | null {
const data = image.data?.trim() ?? "";
const mimeType = image.mimeType.trim();
if (!data || !mimeType) return null;
return data.startsWith("data:") ? data : `data:${mimeType};base64,${data}`;
}
function imageSourceUrl(image: AgentMessageImageVM): string | null {
const url = image.url?.trim() ?? "";
return url || imageDataUrl(image);
}
function imageFailureSource(
image: AgentMessageImageVM,
src: string | null,
runtime: AgentGUIRuntime | null,
unavailableReason?: ImageFailureUnavailableReason
): ImageFailureSource {
if (src) {
return { kind: "resolved", src };
}
const attachmentId = image.attachmentId?.trim() ?? "";
return {
kind: "locator",
workspaceId: image.workspaceId?.trim() ?? "",
agentSessionId: image.agentSessionId.trim(),
attachmentId,
path: image.path?.trim() ?? "",
readerAvailable: Boolean(
attachmentId ? runtime?.readSessionAttachment : runtime?.readPromptAsset
),
...(unavailableReason ? { unavailableReason } : {})
};
}
function canRetryImageFailure(source: ImageFailureSource): boolean {
return (
source.kind === "resolved" ||
(source.readerAvailable && !source.unavailableReason)
);
}
function imageFailureLabelKey(
source: ImageFailureSource
):
| "agentHost.agentGui.imageLoadFailed"
| "agentHost.agentGui.imageTemporarilyUnavailable" {
return source.kind === "locator" &&
(source.unavailableReason === "source_vm" || !source.readerAvailable)
? "agentHost.agentGui.imageTemporarilyUnavailable"
: "agentHost.agentGui.imageLoadFailed";
}
function isSourceVMUnavailableError(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error as { code?: unknown }).code ===
"agent_session_attachment_vm_unavailable"
);
}
function isMatchingImageFailure(
failed: ImageFailureSource | undefined,
current: ImageFailureSource
): boolean {
if (!failed || failed.kind !== current.kind) return false;
if (failed.kind === "resolved" && current.kind === "resolved") {
return failed.src === current.src;
}
if (failed.kind !== "locator" || current.kind !== "locator") return false;
return (
failed.workspaceId === current.workspaceId &&
failed.agentSessionId === current.agentSessionId &&
failed.attachmentId === current.attachmentId &&
failed.path === current.path &&
failed.readerAvailable === current.readerAvailable
);
}
@@ -0,0 +1,559 @@
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type FocusEvent,
type JSX,
type MouseEvent,
type WheelEvent
} from "react";
import type { AgentMessageLocatorItem } from "./agentTranscriptModel";
import type { AgentConversationFollowEndMode } from "../agentConversationFollowEndController";
import {
findMessageLocatorScrollParent,
useAgentMessageLocatorSelection,
type AgentMessageLocatorVirtualSelectionSource
} from "./useAgentMessageLocatorSelection";
export { findMessageLocatorScrollParent } from "./useAgentMessageLocatorSelection";
const AGENT_MESSAGE_LOCATOR_PANEL_FADE_MS = 160;
const AGENT_MESSAGE_LOCATOR_ITEM_SPACING_PX = 30;
const AGENT_MESSAGE_LOCATOR_HIT_SIZE_PX = 36;
const AGENT_MESSAGE_LOCATOR_BOTTOM_SAFE_INSET_MAX_PX = 48;
const AGENT_MESSAGE_LOCATOR_SCROLL_DURATION_MS = 160;
const agentMessageLocatorScrollAnimations = new WeakMap<HTMLElement, number>();
interface AgentMessageLocatorVisibleFrame {
heightPx: number;
topOffsetPx: number;
}
export function AgentMessageLocatorRail({
followEndMode,
items,
isVisible = true,
label,
onLocate,
virtualSelectionSource
}: {
followEndMode?: AgentConversationFollowEndMode;
items: readonly AgentMessageLocatorItem[];
isVisible?: boolean;
label?: string;
onLocate: (item: AgentMessageLocatorItem) => void;
virtualSelectionSource?: AgentMessageLocatorVirtualSelectionSource;
}): JSX.Element | null {
const locatorRef = useRef<HTMLElement | null>(null);
const locatorViewportRef = useRef<HTMLDivElement | null>(null);
const closePanelTimeoutRef = useRef<number | null>(null);
const [isPanelOpen, setIsPanelOpen] = useState(false);
const [shouldRenderPanel, setShouldRenderPanel] = useState(false);
const [activeKey, setActiveKey] = useState<string | null>(null);
const { selectItem, selectedKey } = useAgentMessageLocatorSelection({
followEndMode,
items,
isVisible,
locatorRef,
virtualSelectionSource
});
const previousAgentResponseByKeyRef = useRef<ReadonlyMap<
string,
boolean
> | null>(null);
const [unreadAgentResponseKeys, setUnreadAgentResponseKeys] = useState<
ReadonlySet<string>
>(new Set());
const [visibleFrame, setVisibleFrame] =
useState<AgentMessageLocatorVisibleFrame | null>(null);
useEffect(() => {
if (isPanelOpen) {
setShouldRenderPanel(true);
return;
}
// timing: keep the panel mounted through its close-fade transition
const timeout = window.setTimeout(
() => setShouldRenderPanel(false),
AGENT_MESSAGE_LOCATOR_PANEL_FADE_MS
);
return () => window.clearTimeout(timeout);
}, [isPanelOpen]);
useEffect(
() => () => {
if (closePanelTimeoutRef.current !== null) {
window.clearTimeout(closePanelTimeoutRef.current);
}
},
[]
);
useEffect(() => {
const previousAgentResponseByKey = previousAgentResponseByKeyRef.current;
const currentKeys = new Set(items.map((item) => item.key));
setUnreadAgentResponseKeys((currentUnreadKeys) => {
let nextUnreadKeys: Set<string> | null = null;
const ensureNextUnreadKeys = (): Set<string> => {
if (!nextUnreadKeys) {
nextUnreadKeys = new Set(currentUnreadKeys);
}
return nextUnreadKeys;
};
for (const key of currentUnreadKeys) {
if (!currentKeys.has(key)) {
ensureNextUnreadKeys().delete(key);
}
}
if (previousAgentResponseByKey) {
for (const item of items) {
const hadAgentResponse =
previousAgentResponseByKey.get(item.key) ?? false;
if (
previousAgentResponseByKey.has(item.key) &&
item.hasAgentResponse &&
!hadAgentResponse &&
item.key !== selectedKey
) {
ensureNextUnreadKeys().add(item.key);
}
}
}
return nextUnreadKeys ?? currentUnreadKeys;
});
previousAgentResponseByKeyRef.current = new Map(
items.map((item) => [item.key, item.hasAgentResponse])
);
}, [items, selectedKey]);
useEffect(() => {
if (!selectedKey) {
return;
}
setUnreadAgentResponseKeys((currentUnreadKeys) => {
if (!currentUnreadKeys.has(selectedKey)) {
return currentUnreadKeys;
}
const nextUnreadKeys = new Set(currentUnreadKeys);
nextUnreadKeys.delete(selectedKey);
return nextUnreadKeys;
});
}, [selectedKey]);
useLayoutEffect(() => {
if (!isVisible) {
return;
}
const locator = locatorRef.current;
const scrollParent = locator
? findMessageLocatorScrollParent(locator)
: null;
if (!scrollParent) {
return;
}
let animationFrame: number | null = null;
const updateVisibleFrame = (): void => {
animationFrame = null;
const nextFrame = readMessageLocatorVisibleFrame(scrollParent);
setVisibleFrame((current) =>
current?.heightPx === nextFrame.heightPx &&
current.topOffsetPx === nextFrame.topOffsetPx
? current
: nextFrame
);
};
const scheduleUpdate = (): void => {
if (animationFrame !== null) {
return;
}
animationFrame = window.requestAnimationFrame(updateVisibleFrame);
};
scheduleUpdate();
const resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(scheduleUpdate);
resizeObserver?.observe(scrollParent);
window.addEventListener("resize", scheduleUpdate);
return () => {
resizeObserver?.disconnect();
window.removeEventListener("resize", scheduleUpdate);
if (animationFrame !== null) {
window.cancelAnimationFrame(animationFrame);
}
};
}, [isVisible, items.length]);
useLayoutEffect(() => {
if (!isVisible) {
return;
}
const selectedIndex = selectedKey
? items.findIndex((item) => item.key === selectedKey)
: -1;
const viewport = locatorViewportRef.current;
if (selectedIndex < 0 || !viewport) {
return;
}
const railHeight =
(items.length - 1) * AGENT_MESSAGE_LOCATOR_ITEM_SPACING_PX +
AGENT_MESSAGE_LOCATOR_HIT_SIZE_PX;
const viewportHeight = Math.min(
railHeight,
visibleFrame?.heightPx ?? railHeight
);
scrollMessageLocatorViewportToIndex(
viewport,
selectedIndex,
viewportHeight
);
}, [isVisible, items, selectedKey, visibleFrame]);
if (items.length < 2) {
return null;
}
const railHeight =
(items.length - 1) * AGENT_MESSAGE_LOCATOR_ITEM_SPACING_PX +
AGENT_MESSAGE_LOCATOR_HIT_SIZE_PX;
const viewportHeight =
visibleFrame === null
? railHeight
: Math.min(railHeight, visibleFrame.heightPx);
const activeOrSelectedKey = activeKey ?? selectedKey;
const markItemRead = (itemKey: string): void => {
setUnreadAgentResponseKeys((currentUnreadKeys) => {
if (!currentUnreadKeys.has(itemKey)) {
return currentUnreadKeys;
}
const nextUnreadKeys = new Set(currentUnreadKeys);
nextUnreadKeys.delete(itemKey);
return nextUnreadKeys;
});
};
const handleLocateItem = (item: AgentMessageLocatorItem): void => {
selectItem(item.key);
setActiveKey(item.key);
markItemRead(item.key);
onLocate(item);
};
const itemFromPointerEvent = (
event: MouseEvent<HTMLElement>
): AgentMessageLocatorItem | null => {
const viewport = locatorViewportRef.current;
if (!viewport) {
return null;
}
const rect = viewport.getBoundingClientRect();
const pointerY = event.clientY - rect.top + viewport.scrollTop;
const index = Math.round(
(pointerY - AGENT_MESSAGE_LOCATOR_HIT_SIZE_PX / 2) /
AGENT_MESSAGE_LOCATOR_ITEM_SPACING_PX
);
return items[Math.max(0, Math.min(items.length - 1, index))] ?? null;
};
const handleViewportClick = (event: MouseEvent<HTMLDivElement>): void => {
if ((event.target as HTMLElement | null)?.closest("button")) {
return;
}
const item = itemFromPointerEvent(event);
if (item) {
handleLocateItem(item);
}
};
const handleViewportMouseMove = (event: MouseEvent<HTMLDivElement>): void => {
const item = itemFromPointerEvent(event);
if (item) {
setActiveKey(item.key);
}
};
const openPanel = (): void => {
if (closePanelTimeoutRef.current !== null) {
window.clearTimeout(closePanelTimeoutRef.current);
closePanelTimeoutRef.current = null;
}
setIsPanelOpen(true);
};
const closePanelSoon = (): void => {
if (closePanelTimeoutRef.current !== null) {
window.clearTimeout(closePanelTimeoutRef.current);
}
// timing: delay closing so pointer can move from trigger into panel content
closePanelTimeoutRef.current = window.setTimeout(() => {
closePanelTimeoutRef.current = null;
setIsPanelOpen(false);
setActiveKey(null);
}, 120);
};
const closePanelNow = (): void => {
if (closePanelTimeoutRef.current !== null) {
window.clearTimeout(closePanelTimeoutRef.current);
closePanelTimeoutRef.current = null;
}
setIsPanelOpen(false);
setActiveKey(null);
};
const handleBlurCapture = (event: FocusEvent<HTMLElement>): void => {
const nextTarget = event.relatedTarget;
if (
nextTarget instanceof Node &&
event.currentTarget.contains(nextTarget)
) {
return;
}
closePanelNow();
};
return (
<nav
ref={locatorRef}
className="agent-gui-message-locator"
aria-label={label ?? items[0]?.summary}
data-agent-transcript-scroll-away-intent
data-testid="agent-message-locator"
onBlurCapture={handleBlurCapture}
onFocusCapture={openPanel}
onMouseEnter={openPanel}
onMouseLeave={closePanelSoon}
style={
{
"--agent-message-locator-height": `${railHeight}px`,
"--agent-message-locator-viewport-height": `${viewportHeight}px`,
...(visibleFrame !== null
? {
"--agent-message-locator-visible-height": `${visibleFrame.heightPx}px`,
"--agent-message-locator-visible-top-offset": `${visibleFrame.topOffsetPx}px`
}
: {})
} as CSSProperties
}
>
<div
ref={locatorViewportRef}
className="agent-gui-message-locator__viewport"
data-testid="agent-message-locator-viewport"
onClick={handleViewportClick}
onMouseMove={handleViewportMouseMove}
>
<div
className="agent-gui-message-locator__content"
style={
{
"--agent-message-locator-height": `${railHeight}px`
} as CSSProperties
}
>
{items.slice(0, -1).map((item, index) => (
<div
key={`segment:${item.key}`}
className="agent-gui-message-locator__track-segment"
style={
{
"--agent-message-locator-segment-position": `${
AGENT_MESSAGE_LOCATOR_HIT_SIZE_PX / 2 +
(index + 0.5) * AGENT_MESSAGE_LOCATOR_ITEM_SPACING_PX
}px`
} as CSSProperties
}
aria-hidden="true"
/>
))}
{items.map((item, index) => (
<button
key={item.key}
type="button"
className="agent-gui-message-locator__tick nodrag tsh-desktop-no-drag"
style={
{
"--agent-message-locator-position": `${
index * AGENT_MESSAGE_LOCATOR_ITEM_SPACING_PX +
AGENT_MESSAGE_LOCATOR_HIT_SIZE_PX / 2
}px`
} as CSSProperties
}
aria-label={item.summary}
title={item.summary}
data-selected={item.key === selectedKey ? "true" : undefined}
data-agent-message-locator-turn-group-index={item.turnGroupIndex}
data-unread-agent-response={
unreadAgentResponseKeys.has(item.key) ? "true" : undefined
}
onClick={() => handleLocateItem(item)}
onFocus={() => setActiveKey(item.key)}
onMouseEnter={() => setActiveKey(item.key)}
>
<span
className="agent-gui-message-locator__dot"
aria-hidden="true"
/>
</button>
))}
</div>
</div>
{shouldRenderPanel ? (
<div
className="agent-gui-message-locator__panel"
role="tooltip"
data-open={isPanelOpen ? "true" : undefined}
data-testid="agent-message-locator-panel"
onMouseEnter={openPanel}
onMouseLeave={closePanelSoon}
onWheel={containMessageLocatorPanelWheel}
>
{items.map((item) => (
<button
key={`panel:${item.key}`}
type="button"
className="agent-gui-message-locator__panel-item nodrag tsh-desktop-no-drag"
data-active={
item.key === activeOrSelectedKey ? "true" : undefined
}
onClick={() => handleLocateItem(item)}
onFocus={() => setActiveKey(item.key)}
onMouseEnter={() => setActiveKey(item.key)}
>
<span className="agent-gui-message-locator__panel-item-text">
{item.summary}
</span>
</button>
))}
</div>
) : null}
</nav>
);
}
function scrollMessageLocatorViewportToIndex(
viewport: HTMLElement,
selectedIndex: number,
viewportHeight: number
): void {
const selectedTop =
selectedIndex * AGENT_MESSAGE_LOCATOR_ITEM_SPACING_PX -
AGENT_MESSAGE_LOCATOR_HIT_SIZE_PX / 2;
const selectedBottom = selectedTop + AGENT_MESSAGE_LOCATOR_HIT_SIZE_PX;
const padding = AGENT_MESSAGE_LOCATOR_HIT_SIZE_PX;
const currentTop = viewport.scrollTop;
const currentBottom = currentTop + viewportHeight;
if (selectedTop < currentTop + padding) {
viewport.scrollTop = Math.max(0, selectedTop - padding);
return;
}
if (selectedBottom > currentBottom - padding) {
viewport.scrollTop = Math.max(0, selectedBottom - viewportHeight + padding);
}
}
export function scrollTranscriptRowIntoView(
row: HTMLElement,
scrollParent: HTMLElement | null
): void {
if (!scrollParent) {
row.scrollIntoView({
block: "center",
behavior: "smooth"
});
return;
}
const targetScrollTop = targetScrollTopForTranscriptRow(row, scrollParent);
animateMessageLocatorScroll(scrollParent, targetScrollTop);
}
function targetScrollTopForTranscriptRow(
row: HTMLElement,
scrollParent: HTMLElement
): number {
const rowRect = row.getBoundingClientRect();
const scrollParentRect = scrollParent.getBoundingClientRect();
const visibleFrame = readMessageLocatorVisibleFrame(scrollParent);
const rowCenter =
rowRect.top -
scrollParentRect.top +
scrollParent.scrollTop +
rowRect.height / 2;
const maxScrollTop = Math.max(
0,
scrollParent.scrollHeight - scrollParent.clientHeight
);
const targetScrollTop =
rowCenter - visibleFrame.topOffsetPx - visibleFrame.heightPx / 2;
return Math.min(maxScrollTop, Math.max(0, targetScrollTop));
}
function animateMessageLocatorScroll(
scrollParent: HTMLElement,
targetScrollTop: number
): void {
const previousAnimation =
agentMessageLocatorScrollAnimations.get(scrollParent);
if (previousAnimation !== undefined) {
window.cancelAnimationFrame(previousAnimation);
}
const startScrollTop = scrollParent.scrollTop;
const delta = targetScrollTop - startScrollTop;
const startedAt = performance.now();
const step = (now: number): void => {
const progress = Math.min(
1,
(now - startedAt) / AGENT_MESSAGE_LOCATOR_SCROLL_DURATION_MS
);
scrollParent.scrollTop = startScrollTop + delta * progress;
if (progress >= 1) {
scrollParent.scrollTop = targetScrollTop;
agentMessageLocatorScrollAnimations.delete(scrollParent);
return;
}
agentMessageLocatorScrollAnimations.set(
scrollParent,
window.requestAnimationFrame(step)
);
};
agentMessageLocatorScrollAnimations.set(
scrollParent,
window.requestAnimationFrame(step)
);
}
function readMessageLocatorVisibleFrame(
scrollParent: HTMLElement
): AgentMessageLocatorVisibleFrame {
const style = window.getComputedStyle(scrollParent);
const topOffsetPx = parseCssPx(style.scrollPaddingTop);
const bottomOffsetPx = Math.min(
parseCssPx(style.scrollPaddingBottom),
AGENT_MESSAGE_LOCATOR_BOTTOM_SAFE_INSET_MAX_PX
);
return {
heightPx: Math.max(
0,
scrollParent.clientHeight - topOffsetPx - bottomOffsetPx
),
topOffsetPx
};
}
function parseCssPx(value: string): number {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
}
function containMessageLocatorPanelWheel(
event: WheelEvent<HTMLDivElement>
): void {
event.stopPropagation();
if (event.deltaY === 0) {
return;
}
event.preventDefault();
event.currentTarget.scrollTop += event.deltaY;
}
@@ -0,0 +1,35 @@
import { fireEvent, render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AgentPlanCard } from "./AgentPlanCard";
describe("AgentPlanCard", () => {
it("starts collapsed and expands on demand", () => {
const { getByTestId, queryByTestId } = render(
<AgentPlanCard copyText="# Plan">
<div>plan body</div>
</AgentPlanCard>
);
const card = getByTestId("agent-plan-card");
expect(card.getAttribute("data-collapsed")).toBe("true");
expect(getByTestId("agent-plan-card-expand")).toBeTruthy();
fireEvent.click(getByTestId("agent-plan-card-expand"));
expect(card.getAttribute("data-collapsed")).toBe("false");
expect(queryByTestId("agent-plan-card-expand")).toBeNull();
});
it("copies the plan markdown to the clipboard", () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, { clipboard: { writeText } });
const { getByTestId } = render(
<AgentPlanCard copyText="plan-copy-text">
<div>plan body</div>
</AgentPlanCard>
);
fireEvent.click(getByTestId("agent-plan-card-copy"));
expect(writeText).toHaveBeenCalledWith("plan-copy-text");
});
});
@@ -0,0 +1,123 @@
import { useCallback, useRef, useState, type JSX, type ReactNode } from "react";
import { Check, ChevronDown, ChevronUp, Copy, ListChecks } from "lucide-react";
import { translate } from "../../../i18n/index";
const COLLAPSED_MAX_HEIGHT_PX = 220;
/**
* Shared collapsible plan card used by both providers' plan proposals so the
* codex plan item and the claude-code ExitPlanMode plan render identically:
* a framed card with a title, a copy action, and a collapse/expand affordance
* (long plans default to collapsed with an "expand plan" overlay).
*/
export function AgentPlanCard({
title,
copyText,
children,
defaultCollapsed = true
}: {
title?: string;
copyText: string;
children: ReactNode;
defaultCollapsed?: boolean;
}): JSX.Element {
"use memo";
const [collapsed, setCollapsed] = useState(defaultCollapsed);
const [copied, setCopied] = useState(false);
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleCopy = useCallback(() => {
void navigator.clipboard?.writeText(copyText).then(() => {
setCopied(true);
if (copyResetRef.current) {
clearTimeout(copyResetRef.current);
}
copyResetRef.current = setTimeout(() => setCopied(false), 1500);
});
}, [copyText]);
return (
<section
data-testid="agent-plan-card"
data-collapsed={collapsed ? "true" : "false"}
className="box-border w-full min-w-0 rounded-[8px] border border-[var(--tutti-purple-border)] bg-[var(--tutti-purple-bg)] p-3"
>
<div className="mb-2 flex items-center gap-1.5">
<ListChecks
size={14}
strokeWidth={2}
aria-hidden="true"
className="shrink-0 text-[var(--text-secondary)]"
/>
<span
data-testid="agent-plan-card-title"
className="flex-1 truncate text-[12px] font-medium text-[var(--text-secondary)]"
>
{title ?? translate("agentHost.agentGui.planCardTitle")}
</span>
<button
type="button"
data-testid="agent-plan-card-copy"
className="inline-flex size-6 shrink-0 items-center justify-center rounded-[6px] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--transparency-hover)] hover:text-[var(--text-secondary)]"
aria-label={translate("agentHost.agentGui.planCardCopy")}
title={translate("agentHost.agentGui.planCardCopy")}
onClick={handleCopy}
>
{copied ? (
<Check size={14} strokeWidth={2} aria-hidden="true" />
) : (
<Copy size={14} strokeWidth={2} aria-hidden="true" />
)}
</button>
<button
type="button"
data-testid="agent-plan-card-toggle"
className="inline-flex size-6 shrink-0 items-center justify-center rounded-[6px] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--transparency-hover)] hover:text-[var(--text-secondary)]"
aria-expanded={!collapsed}
aria-label={translate(
collapsed
? "agentHost.agentGui.planCardExpand"
: "agentHost.agentGui.planCardCollapse"
)}
title={translate(
collapsed
? "agentHost.agentGui.planCardExpand"
: "agentHost.agentGui.planCardCollapse"
)}
onClick={() => setCollapsed((value) => !value)}
>
{collapsed ? (
<ChevronDown size={15} strokeWidth={2} aria-hidden="true" />
) : (
<ChevronUp size={15} strokeWidth={2} aria-hidden="true" />
)}
</button>
</div>
<div className="relative">
<div
data-testid="agent-plan-card-body"
className="min-w-0 overflow-hidden"
style={
collapsed
? { maxHeight: `${COLLAPSED_MAX_HEIGHT_PX}px` }
: undefined
}
>
{children}
</div>
{collapsed ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center bg-gradient-to-t from-[var(--tutti-purple-bg)] via-[color-mix(in_srgb,var(--tutti-purple-bg)_70%,transparent)] to-transparent pt-8 pb-1">
<button
type="button"
data-testid="agent-plan-card-expand"
className="pointer-events-auto inline-flex h-7 items-center rounded-full border border-[var(--line-2)] bg-[var(--background-fronted)] px-3 text-[12px] font-medium text-[var(--text-secondary)] transition-colors hover:text-[var(--text-primary)]"
onClick={() => setCollapsed(false)}
>
{translate("agentHost.agentGui.planCardExpand")}
</button>
</div>
) : null}
</div>
</section>
);
}
@@ -0,0 +1,47 @@
import type { JSX } from "react";
import type { AgentProcessingRowVM } from "../contracts/agentProcessingRowVM";
export function AgentProcessingRow({
row,
label,
paused = false
}: {
row: AgentProcessingRowVM;
label: string;
paused?: boolean;
}): JSX.Element {
"use memo";
return (
<div
data-row-id={row.id}
className="workspace-agents-status-panel__detail-processing inline-flex items-center gap-1.5"
>
<span className="inline-flex min-w-0 items-center gap-1 font-semibold">
<span>{processingLabel(row, label)}</span>
{paused ? null : <LoadingEllipsis />}
</span>
</div>
);
}
function processingLabel(row: AgentProcessingRowVM, fallback: string): string {
if (row.label?.trim()) {
return row.label.trim();
}
return fallback;
}
function LoadingEllipsis(): JSX.Element {
"use memo";
return (
<span
className="tsh-inline-loading-ellipsis tsh-inline-loading-ellipsis--entry-timing"
aria-hidden="true"
>
<span />
<span />
<span />
</span>
);
}
@@ -0,0 +1,38 @@
import { MessageSquareText } from "lucide-react";
import type { JSX } from "react";
import { useTranslation } from "../../../i18n/index";
import type { AgentSelectedTextVM } from "../contracts/agentMessageRowVM";
export function AgentSelectedTextChip({
selectedText
}: {
selectedText: AgentSelectedTextVM;
}): JSX.Element {
"use memo";
const { t } = useTranslation();
const count = selectedText.count;
const label =
count === 1
? t("agentHost.agentGui.selectionReferenceCountOne")
: t("agentHost.agentGui.selectionReferenceCountMany", {
count: String(count)
});
return (
<div
data-testid="agent-selected-text-chip"
data-selected-text-count={count}
className="group inline-flex max-w-full items-center rounded-[10px] border border-[var(--line-1)] bg-[var(--background-fronted)] text-sm font-medium text-[var(--text-primary)]"
>
<span className="inline-flex min-w-0 items-center gap-2 rounded-[9px] py-2 pl-3 pr-2">
<MessageSquareText
aria-hidden="true"
className="shrink-0 text-[var(--text-secondary)]"
size={16}
strokeWidth={2}
/>
<span className="truncate">{label}</span>
</span>
</div>
);
}
@@ -0,0 +1,341 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { setAgentGuiI18nTestLocale } from "../../../i18n/testUtils";
import type { AgentTaskSubAgentVM } from "../contracts/agentTaskItemVM";
import { AgentSubAgentCard } from "./AgentSubAgentCards";
describe("AgentSubAgentCard", () => {
afterEach(() => {
vi.useRealTimers();
setAgentGuiI18nTestLocale("zh-CN");
});
it("keeps ticking while running even when no new sub-agent activity arrives", async () => {
setAgentGuiI18nTestLocale("en");
vi.useFakeTimers();
vi.setSystemTime(1_000);
render(
<AgentSubAgentCard
subAgent={subAgent({
status: "running",
startedAtUnixMs: 1_000,
latestActivityAtUnixMs: 1_000
})}
/>
);
expect(screen.getByText("Starting…")).toBeInTheDocument();
await act(async () => {
await vi.advanceTimersByTimeAsync(2_000);
});
expect(screen.getByText(/2\.0s · Running/)).toBeInTheDocument();
await act(async () => {
await vi.advanceTimersByTimeAsync(9_000);
});
expect(screen.getByText(/11s · Running/)).toBeInTheDocument();
});
it("does not tick while queued for a concurrency slot, even as time passes", async () => {
setAgentGuiI18nTestLocale("en");
vi.useFakeTimers();
vi.setSystemTime(1_000);
render(
<AgentSubAgentCard
subAgent={subAgent({
status: "running",
queued: true,
startedAtUnixMs: 1_000,
latestActivityAtUnixMs: 1_000
})}
/>
);
expect(
screen.getByText("Queued — waiting for an agent slot…")
).toBeInTheDocument();
await act(async () => {
await vi.advanceTimersByTimeAsync(11_000);
});
// A queued sub-agent has not been dispatched yet: startedAtUnixMs is the
// spawn call's own (dispatch) time, not a real run-start time, so no
// elapsed clock must appear or tick while queued=true.
expect(screen.queryByText(/\d+(\.\d+)?s ·/)).not.toBeInTheDocument();
expect(
screen.getByText("Queued — waiting for an agent slot…")
).toBeInTheDocument();
});
it("starts ticking only once dispatch clears the queue", async () => {
setAgentGuiI18nTestLocale("en");
vi.useFakeTimers();
vi.setSystemTime(1_000);
const { rerender } = render(
<AgentSubAgentCard
subAgent={subAgent({
status: "running",
queued: true,
startedAtUnixMs: 1_000,
latestActivityAtUnixMs: 1_000
})}
/>
);
await act(async () => {
await vi.advanceTimersByTimeAsync(9_000);
});
expect(screen.queryByText(/\d+(\.\d+)?s ·/)).not.toBeInTheDocument();
// The sub-agent clears the queue at t=10_000 and actually starts running.
vi.setSystemTime(10_000);
rerender(
<AgentSubAgentCard
subAgent={subAgent({
status: "running",
queued: false,
startedAtUnixMs: 10_000,
latestActivityAtUnixMs: 10_000
})}
/>
);
await act(async () => {
await vi.advanceTimersByTimeAsync(2_000);
});
expect(screen.getByText(/2\.0s · Running/)).toBeInTheDocument();
});
it("freezes terminal elapsed time instead of following Date.now", async () => {
setAgentGuiI18nTestLocale("en");
vi.useFakeTimers();
vi.setSystemTime(10_000);
render(
<AgentSubAgentCard
subAgent={subAgent({
status: "completed",
startedAtUnixMs: 1_000,
latestActivityAtUnixMs: 101_000,
terminalAtUnixMs: 6_000
})}
/>
);
expect(screen.getByText(/5\.0s · Completed/)).toBeInTheDocument();
await act(async () => {
vi.setSystemTime(120_000);
await vi.advanceTimersByTimeAsync(5_000);
});
expect(screen.getByText(/5\.0s · Completed/)).toBeInTheDocument();
expect(screen.queryByText(/1m 59s/)).not.toBeInTheDocument();
});
it("shows completed instead of starting when terminal output is absent", async () => {
setAgentGuiI18nTestLocale("en");
render(
<AgentSubAgentCard
subAgent={subAgent({
status: "completed",
terminalAtUnixMs: 2_000
})}
/>
);
fireEvent.click(
screen.getByRole("button", {
name: /Sub-agent Repo smell analyst Completed/
})
);
await act(async () => {
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
});
expect(screen.getByText("Completed")).toBeInTheDocument();
expect(screen.queryByText("Starting…")).not.toBeInTheDocument();
});
it.each(["running", "completed"] as const)(
"renders complete Markdown and routes its link while %s",
async (status) => {
setAgentGuiI18nTestLocale("en");
const onLinkClick = vi.fn();
render(
<AgentSubAgentCard
subAgent={subAgent({
status,
assistantMarkdown:
"Earlier context remains available in the [report](https://example.com/report.pdf)",
latestActivity: "…report.pdf)",
terminalAtUnixMs: 2_000
})}
onLinkClick={onLinkClick}
/>
);
if (status === "completed") {
fireEvent.click(
screen.getByRole("button", {
name: /Sub-agent Repo smell analyst Completed/
})
);
}
fireEvent.click(await screen.findByRole("link", { name: "report" }));
expect(onLinkClick).toHaveBeenCalledWith(
"https://example.com/report.pdf"
);
expect(screen.queryByText("…report.pdf)")).not.toBeInTheDocument();
}
);
it("shows failed instead of starting when failure detail is absent", async () => {
setAgentGuiI18nTestLocale("en");
render(
<AgentSubAgentCard
subAgent={subAgent({
status: "failed",
terminalAtUnixMs: 2_000
})}
/>
);
fireEvent.click(
screen.getByRole("button", {
name: /Sub-agent Repo smell analyst Failed/
})
);
await act(async () => {
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
});
expect(
screen.getByText("Failed", {
selector:
".workspace-agents-status-panel__detail-subagent-activity--in-terminal"
})
).toBeInTheDocument();
expect(screen.queryByText("Starting…")).not.toBeInTheDocument();
});
it("renders a completed Markdown result without duplicating its progress summary", async () => {
setAgentGuiI18nTestLocale("en");
const onLinkClick = vi.fn();
render(
<AgentSubAgentCard
onLinkClick={onLinkClick}
subAgent={subAgent({
status: "completed",
latestActivity: "Full result",
assistantMarkdown:
"## Full result\n\n[report](https://example.com/report.pdf)",
terminalAtUnixMs: 2_000
})}
/>
);
fireEvent.click(
screen.getByRole("button", {
name: /Sub-agent Repo smell analyst Completed/
})
);
expect(
await screen.findByRole("heading", { name: "Full result" })
).toBeInTheDocument();
fireEvent.click(screen.getByRole("link", { name: "report" }));
expect(onLinkClick).toHaveBeenCalledWith("https://example.com/report.pdf");
expect(
screen.queryByText("Full result", { selector: "div" })
).not.toBeInTheDocument();
});
it("keeps partial assistant Markdown alongside a terminal failure", async () => {
setAgentGuiI18nTestLocale("en");
render(
<AgentSubAgentCard
subAgent={subAgent({
status: "failed",
assistantMarkdown: "Partial **report**",
failureDetail: "Provider connection failed",
terminalAtUnixMs: 2_000
})}
/>
);
fireEvent.click(
screen.getByRole("button", {
name: /Sub-agent Repo smell analyst Failed/
})
);
expect(await screen.findByText("report")).toBeInTheDocument();
expect(screen.getByText("Provider connection failed")).toBeInTheDocument();
});
it("updates the same child from streaming Markdown to its completed result", async () => {
setAgentGuiI18nTestLocale("en");
const { rerender } = render(
<AgentSubAgentCard
subAgent={subAgent({
status: "running",
assistantMarkdown: "Streaming opening"
})}
/>
);
expect(await screen.findByText("Streaming opening")).toBeInTheDocument();
rerender(
<AgentSubAgentCard
subAgent={subAgent({
status: "completed",
assistantMarkdown: "Streaming opening with **completed result**",
terminalAtUnixMs: 2_000
})}
/>
);
expect(await screen.findByText("completed result")).toBeInTheDocument();
});
});
function subAgent(
overrides: Partial<AgentTaskSubAgentVM> = {}
): AgentTaskSubAgentVM {
return {
childSessionId: "child-session-1",
parentToolCallId: "spawn-1",
status: "running",
name: "Repo smell analyst",
task: "inspect the repository",
laneIndex: 1,
laneCount: 1,
latestActivity: null,
latestActivityKind: null,
activityLog: [],
activityOmittedCount: 0,
failureDetail: null,
startedAtUnixMs: 1_000,
latestActivityAtUnixMs: 1_000,
terminalAtUnixMs: null,
childSessions: [],
...overrides
};
}
@@ -0,0 +1,340 @@
import { memo, useState, type JSX } from "react";
import { AlertCircle, ChevronDown, ChevronRight } from "lucide-react";
import { AgentLinedIcon } from "../../../app/renderer/components/icons/AgentLinedIcon";
import { translate } from "../../../i18n/index";
import type { AgentTaskSubAgentVM } from "../contracts/agentTaskItemVM";
import type { AgentToolCallVM } from "../contracts/agentToolCallVM";
import { CollapsibleReveal } from "./CollapsibleReveal";
import { useAgentConversationNowUnixMs } from "./AgentConversationClock";
import { formatAgentToolDurationMs } from "./tool-renderers/render-data/agentToolRenderData";
import { AgentMessageMarkdown } from "../../AgentMessageMarkdown";
// A delegated sub-agent renders as a first-class row aligned with tool rows:
// header = "Sub-agent · <name> · <elapsed> · <status>" with a chevron, body =
// the indented detail panel (TASK / PROGRESS). Identity and status are the
// sub-agent's own (child thread nickname + lifecycle), never the spawn tool's.
export function AgentSubAgentCards({
call,
onLinkClick
}: {
call: AgentToolCallVM;
onLinkClick?: (href: string) => void;
}): JSX.Element | null {
"use memo";
const subAgents = call.task?.subAgents ?? [];
if (subAgents.length === 0) {
return null;
}
// No wrapper element: the transcript row's spacing-compression selectors
// match direct .detail-tool-row children, keeping gaps identical to tools.
return (
<>
{subAgents.map((subAgent) => (
<AgentSubAgentCard
key={subAgent.childSessionId}
subAgent={subAgent}
onLinkClick={onLinkClick}
/>
))}
</>
);
}
// Field-level memo: streaming updates elsewhere in the transcript must not
// re-render settled cards; a collapsed card renders only its header row
// (CollapsibleReveal lazy-mounts the body on first expand).
export const AgentSubAgentCard = memo(
AgentSubAgentCardImpl,
(prev, next) =>
prev.onLinkClick === next.onLinkClick &&
subAgentVMEquals(prev.subAgent, next.subAgent)
);
function subAgentVMEquals(
left: AgentTaskSubAgentVM,
right: AgentTaskSubAgentVM
): boolean {
return (
left.childSessionId === right.childSessionId &&
left.parentToolCallId === right.parentToolCallId &&
left.status === right.status &&
left.name === right.name &&
left.task === right.task &&
left.laneIndex === right.laneIndex &&
left.laneCount === right.laneCount &&
left.latestActivity === right.latestActivity &&
left.assistantMarkdown === right.assistantMarkdown &&
left.failureDetail === right.failureDetail &&
left.queued === right.queued &&
left.startedAtUnixMs === right.startedAtUnixMs &&
left.latestActivityAtUnixMs === right.latestActivityAtUnixMs &&
left.terminalAtUnixMs === right.terminalAtUnixMs &&
left.childSessions === right.childSessions
);
}
function AgentSubAgentCardImpl({
subAgent,
onLinkClick
}: {
subAgent: AgentTaskSubAgentVM;
onLinkClick?: (href: string) => void;
}): JSX.Element {
"use memo";
// Expanded by default while running; always collapsible via the header.
const [expanded, setExpanded] = useState(subAgent.status === "running");
return (
<div
className="workspace-agents-status-panel__detail-tool-row workspace-agents-status-panel__detail-tool-row--subagent"
data-status={subAgent.status}
>
<button
type="button"
className="workspace-agents-status-panel__detail-tool-row-head workspace-agents-status-panel__detail-tool-row-head--button"
aria-expanded={expanded}
aria-label={subAgentAriaLabel(subAgent)}
onClick={() => setExpanded((value) => !value)}
>
<SubAgentHeader subAgent={subAgent} expanded={expanded} />
</button>
<CollapsibleReveal expanded={expanded}>
<div className="workspace-agents-status-panel__detail-subagent-reveal">
<SubAgentBody subAgent={subAgent} onLinkClick={onLinkClick} />
</div>
</CollapsibleReveal>
</div>
);
}
function SubAgentHeader({
subAgent,
expanded
}: {
subAgent: AgentTaskSubAgentVM;
expanded: boolean;
}): JSX.Element {
"use memo";
const running = subAgent.status === "running";
const statusLabel = subAgentStatusLabel(subAgent.status);
const runningNowUnixMs = useRunningSubAgentNowUnixMs(subAgent);
const elapsedText = subAgentElapsedText(subAgent, runningNowUnixMs);
const nameText = subAgentNameText(subAgent);
return (
<div
data-active={running ? "true" : undefined}
className={[
"workspace-agents-status-panel__detail-tool-row-header-content",
running ? "tsh-inline-scanlight-group" : ""
]
.filter(Boolean)
.join(" ")}
>
<div className="workspace-agents-status-panel__detail-tool-row-icon tsh-inline-scanlight-icon">
{subAgent.status === "failed" ? (
<AlertCircle size={16} strokeWidth={2} aria-hidden="true" />
) : (
<AgentLinedIcon width={16} height={16} aria-hidden="true" />
)}
</div>
<div className="workspace-agents-status-panel__detail-tool-row-text">
<strong className="workspace-agents-status-panel__detail-tool-row-title">
{translate("agentHost.agentTool.details.subAgentFallbackName")}
</strong>
{nameText ? (
<span className="workspace-agents-status-panel__detail-subagent-name">
{nameText}
</span>
) : null}
<span className="workspace-agents-status-panel__detail-tool-status">
{elapsedText ? `${elapsedText} · ` : ""}
{statusLabel}
</span>
</div>
{expanded ? (
<ChevronDown
size={12}
strokeWidth={2.2}
aria-hidden="true"
className="workspace-agents-status-panel__detail-tool-row-chevron"
/>
) : (
<ChevronRight
size={12}
strokeWidth={2.2}
aria-hidden="true"
className="workspace-agents-status-panel__detail-tool-row-chevron"
/>
)}
</div>
);
}
function SubAgentBody({
subAgent,
onLinkClick
}: {
subAgent: AgentTaskSubAgentVM;
onLinkClick?: (href: string) => void;
}): JSX.Element {
"use memo";
// Bash-block layout: the task is the header strip, live progress renders
// below it - no section labels.
return (
<div className="workspace-agents-status-panel__detail-tool-body workspace-agents-status-panel__detail-tool-body--plain">
<div className="workspace-agents-status-panel__detail-subagent-terminal">
{subAgent.task ? (
<div className="workspace-agents-status-panel__detail-subagent-task-strip">
{subAgent.task}
</div>
) : null}
<SubAgentProgress subAgent={subAgent} onLinkClick={onLinkClick} />
{subAgent.childSessions.length > 0 ? (
<div className="workspace-agents-status-panel__detail-subagent-children">
{subAgent.childSessions.map((childSession) => (
<AgentSubAgentCard
key={childSession.childSessionId}
subAgent={childSession}
onLinkClick={onLinkClick}
/>
))}
</div>
) : null}
</div>
</div>
);
}
function SubAgentProgress({
subAgent,
onLinkClick
}: {
subAgent: AgentTaskSubAgentVM;
onLinkClick?: (href: string) => void;
}): JSX.Element {
"use memo";
if (subAgent.assistantMarkdown?.trim()) {
return (
<div className="workspace-agents-status-panel__detail-subagent-activity workspace-agents-status-panel__detail-subagent-activity--in-terminal">
<AgentMessageMarkdown
content={subAgent.assistantMarkdown}
documentCacheKey={`sub-agent:${subAgent.childSessionId}:assistant`}
onLinkClick={onLinkClick}
streaming={subAgent.status === "running"}
enableImageZoom
className="workspace-agents-status-panel__detail-tool-markdown [&_ol]:text-[var(--text-secondary)] [&_ul]:text-[var(--text-secondary)]"
/>
{subAgent.failureDetail ? (
<div className="workspace-agents-status-panel__detail-subagent-activity--failure">
{subAgent.failureDetail}
</div>
) : null}
</div>
);
}
// Tool/reasoning activity remains a short fallback until the child emits an
// assistant message. Once it does, the complete Markdown above owns both the
// streaming and settled presentation.
const text =
subAgent.failureDetail ??
(subAgent.status === "running" ? subAgent.latestActivity : null) ??
translate(
subAgent.status === "completed"
? "agentHost.agentTool.statusCompleted"
: subAgent.status === "canceled"
? "agentHost.agentTool.statusCanceled"
: subAgent.status === "failed"
? "agentHost.agentTool.statusFailed"
: subAgent.queued
? "agentHost.agentTool.details.subAgentQueued"
: "agentHost.agentTool.details.subAgentStarting"
);
return (
<div
className={`workspace-agents-status-panel__detail-subagent-activity workspace-agents-status-panel__detail-subagent-activity--in-terminal${
subAgent.failureDetail
? " workspace-agents-status-panel__detail-subagent-activity--failure"
: ""
}`}
>
{text}
</div>
);
}
function subAgentNameText(subAgent: AgentTaskSubAgentVM): string | null {
if (subAgent.name) {
return subAgent.name;
}
return subAgent.laneCount > 1 ? `#${subAgent.laneIndex}` : null;
}
function subAgentAriaLabel(subAgent: AgentTaskSubAgentVM): string {
return [
translate("agentHost.agentTool.details.subAgentFallbackName"),
subAgentNameText(subAgent),
subAgentStatusLabel(subAgent.status)
]
.filter(Boolean)
.join(" ");
}
function subAgentStatusLabel(status: AgentTaskSubAgentVM["status"]): string {
switch (status) {
case "completed":
return translate("agentHost.agentTool.statusCompleted");
case "failed":
return translate("agentHost.agentTool.statusFailed");
case "canceled":
return translate("agentHost.agentTool.statusCanceled");
case "running":
default:
return translate("agentHost.agentTool.statusWorking");
}
}
// A sub-agent that is merely `queued` has been accepted but has not been
// dispatched to a provider yet (codex caps concurrent sub-agents and queues
// spawns beyond the cap - see AgentTaskSubAgentVM.queued). Its `status` still
// reads "running" (there is no child lifecycle yet to say otherwise) and its
// `startedAtUnixMs` is stamped from the spawn call itself, i.e. dispatch/
// queued time, not from when the sub-agent actually started running. Ticking
// a live clock off that timestamp would count queued wait time as elapsed
// run time, so the clock must stay off until the sub-agent is no longer
// queued.
function isSubAgentActivelyRunning(subAgent: AgentTaskSubAgentVM): boolean {
return subAgent.status === "running" && !subAgent.queued;
}
function useRunningSubAgentNowUnixMs(
subAgent: AgentTaskSubAgentVM
): number | null {
const shouldTick =
isSubAgentActivelyRunning(subAgent) &&
typeof subAgent.startedAtUnixMs === "number";
return useAgentConversationNowUnixMs(shouldTick);
}
function subAgentElapsedText(
subAgent: AgentTaskSubAgentVM,
runningNowUnixMs: number | null
): string | null {
const started = subAgent.startedAtUnixMs;
if (typeof started !== "number") {
return null;
}
const terminal = subAgent.terminalAtUnixMs;
const latest = subAgent.latestActivityAtUnixMs;
const ended =
typeof terminal === "number"
? terminal
: isSubAgentActivelyRunning(subAgent) &&
typeof runningNowUnixMs === "number"
? Math.max(latest ?? started, runningNowUnixMs)
: latest;
if (typeof ended !== "number" || ended <= started) {
return null;
}
return formatAgentToolDurationMs(ended - started);
}
@@ -0,0 +1,247 @@
import type { JSX } from "react";
import { translate } from "../../../i18n/index";
import type { AgentMessageContentVM } from "../contracts/agentMessageRowVM";
import { AgentMessageDetailsDisclosure } from "./AgentMessageDetailsDisclosure";
import agentSystemNoticeStyles from "./agentSystemNoticeStyles";
import { useElapsedSeconds } from "./useElapsedSeconds";
const TRANSPORT_RETRY_PROGRESS_PATTERN =
/\b(reconnect(?:ing)?(?:\s*(?:\.\.\.|…|[.。]+|:|-))?\s*\(?\d+\s*\/\s*\d+\)?)/i;
export function AgentSystemNoticeMessage({
message
}: {
message: AgentMessageContentVM;
}): JSX.Element {
"use memo";
const notice = message.systemNotice;
const detail = notice?.detail?.trim() ?? "";
const title = systemNoticeTitle(message);
if (notice?.noticeKind === "transport_retry") {
const retryText = transportRetryNoticeText(message);
return (
<div
role="status"
className="box-border w-full min-w-0 py-1 text-[13px] leading-5 text-[var(--text-secondary)]"
>
{retryText}
</div>
);
}
if (isContextCompactionProgressNotice(message)) {
return (
<ContextCompactionProgressDivider
startedAtUnixMs={message.occurredAtUnixMs}
/>
);
}
if (isContextCompactionNotice(message)) {
return (
<ContextCompactionDivider
text={translate("agentHost.agentGui.contextCompactionCompleted")}
/>
);
}
if (isContextHandoffRequiredNotice(message)) {
return (
<section
role="alert"
className={`box-border w-full min-w-0 rounded-[8px] border p-3 text-[13px] leading-5 text-[var(--text-primary)] ${agentSystemNoticeStyles.contextHandoff}`}
>
<div className="font-medium text-[var(--state-danger)]">
{translate("agentHost.agentGui.contextHandoffRequired")}
</div>
<div className="mt-1 text-[var(--text-secondary)]">
{translate("agentHost.agentGui.contextHandoffRequiredDetail")}
</div>
{detail ? (
<AgentMessageDetailsDisclosure detail={detail} className="mt-2" />
) : null}
</section>
);
}
if (isContextCompactionInterruptedNotice(message)) {
return (
<ContextCompactionDivider
text={translate("agentHost.agentGui.contextCompactionInterrupted")}
detail={detail || null}
/>
);
}
const isStatusNotice = systemNoticeIsStatus(message);
return (
<section
role={isStatusNotice ? "status" : undefined}
className={`box-border w-full min-w-0 rounded-[8px] border p-3 text-[13px] leading-5 text-[var(--text-secondary)] ${agentSystemNoticeStyles.routine}`}
>
<div className="min-w-0">
<div className="font-medium text-[var(--text-secondary)]">{title}</div>
{detail ? (
<AgentMessageDetailsDisclosure
detail={detail}
className="mt-1"
tone="muted"
/>
) : null}
</div>
</section>
);
}
function systemNoticeIsStatus(message: AgentMessageContentVM): boolean {
const notice = message.systemNotice;
return (
notice?.severity === "warning" ||
notice?.severity === "error" ||
notice?.noticeKind === "transport_fallback" ||
isTransportFallbackNotice(message)
);
}
function isTransportFallbackNotice(message: AgentMessageContentVM): boolean {
const notice = message.systemNotice;
const text = [notice?.title, notice?.detail, message.body]
.filter(Boolean)
.join("\n")
.toLowerCase();
return (
text.includes("falling back from websockets") ||
text.includes("https transport")
);
}
function transportRetryNoticeText(message: AgentMessageContentVM): string {
const notice = message.systemNotice;
const detail = notice?.detail?.trim() ?? "";
const progressText =
transportRetryProgressText(detail) ??
transportRetryProgressText(notice?.title ?? "") ??
transportRetryProgressText(message.body);
if (progressText) {
return progressText;
}
return (
notice?.title?.trim() ||
message.body.trim() ||
translate("agentHost.agentGui.systemNoticeTransportRetry")
);
}
function transportRetryProgressText(value: string): string | null {
const match = TRANSPORT_RETRY_PROGRESS_PATTERN.exec(value.trim());
return match?.[1]?.replace(/\s+/g, " ").trim() || null;
}
function isContextCompactionNotice(message: AgentMessageContentVM): boolean {
const notice = message.systemNotice;
return notice?.command === "compact" && notice.commandStatus === "completed";
}
function isContextCompactionProgressNotice(
message: AgentMessageContentVM
): boolean {
const notice = message.systemNotice;
return notice?.command === "compact" && notice.commandStatus === "running";
}
function isContextCompactionInterruptedNotice(
message: AgentMessageContentVM
): boolean {
const notice = message.systemNotice;
return (
notice?.command === "compact" &&
(notice.commandStatus === "failed" || notice.commandStatus === "canceled")
);
}
function isContextHandoffRequiredNotice(
message: AgentMessageContentVM
): boolean {
return message.systemNotice?.semanticKind === "context-handoff-required";
}
function ContextCompactionDivider({
text,
detail = null
}: {
text: string;
detail?: string | null;
}): JSX.Element {
"use memo";
return (
<div
role="status"
className="box-border w-full min-w-0 py-2 text-[12px] leading-4 text-[var(--text-secondary)]"
>
<div className="flex min-w-0 items-center gap-3">
<span
aria-hidden="true"
className="h-px min-w-4 flex-1 bg-[var(--line-1)]"
/>
<span className="shrink-0 whitespace-nowrap">{text}</span>
<span
aria-hidden="true"
className="h-px min-w-4 flex-1 bg-[var(--line-1)]"
/>
</div>
{detail ? (
<div className="mt-1 min-w-0 whitespace-pre-wrap break-words text-center leading-5">
{detail}
</div>
) : null}
</div>
);
}
// Live compaction banner: the daemon replaces this notice in place with the
// "Context compacted." notice once the provider finishes, so the timer only
// runs while compaction is actually in flight.
function ContextCompactionProgressDivider({
startedAtUnixMs
}: {
startedAtUnixMs: number | null;
}): JSX.Element {
"use memo";
const elapsedSeconds = useElapsedSeconds(startedAtUnixMs);
const label = translate("agentHost.agentGui.contextCompactionInProgress");
const text =
elapsedSeconds === null
? label
: `${label} · ${formatElapsedSeconds(elapsedSeconds)}`;
return <ContextCompactionDivider text={text} />;
}
function formatElapsedSeconds(seconds: number): string {
if (seconds < 60) {
return `${seconds}s`;
}
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
}
function systemNoticeTitle(message: AgentMessageContentVM): string {
const notice = message.systemNotice;
switch (notice?.noticeKind) {
case "transport_retry":
return translate("agentHost.agentGui.systemNoticeTransportRetry");
case "transport_fallback":
return translate("agentHost.agentGui.systemNoticeTransportFallback");
case "plan_implementation_pending_confirmation":
return translate(
"agentHost.agentGui.systemNoticePlanImplementationPendingConfirmation"
);
case "plan_implementation_completed":
return translate(
"agentHost.agentGui.systemNoticePlanImplementationCompleted"
);
case "warning":
return (
notice.title || translate("agentHost.agentGui.systemNoticeWarning")
);
default:
return (
notice?.title ||
message.body ||
translate("agentHost.agentGui.systemNoticeDefault")
);
}
}
@@ -0,0 +1,64 @@
import { useState, type JSX } from "react";
import type { AgentToolCallCardProps } from "./AgentToolCallCard";
import { AgentToolCallHeader } from "./AgentToolCallHeader";
import { CollapsibleReveal } from "./CollapsibleReveal";
import { AgentExpandedToolContent } from "./tool-renderers/AgentExpandedToolContent";
import { hasAgentToolContent } from "./tool-renderers/agentToolContentShared";
export function AgentTaskCallCard({
call,
onLinkClick,
defaultExpanded,
nonCollapsible
}: AgentToolCallCardProps): JSX.Element {
"use memo";
const running =
(call.task?.status ?? call.status ?? "").trim().toLowerCase() === "running";
const hasDetail = hasAgentToolContent(call);
const pinned = nonCollapsible ?? running;
const [expanded, setExpanded] = useState(defaultExpanded ?? running);
const ariaLabel = taskCallAriaLabel(call);
return (
<div className="workspace-agents-status-panel__detail-tool-row workspace-agents-status-panel__detail-tool-row--task">
{hasDetail && !pinned ? (
<button
type="button"
className="workspace-agents-status-panel__detail-tool-row-head workspace-agents-status-panel__detail-tool-row-head--button"
aria-expanded={expanded}
aria-label={ariaLabel}
onClick={() => setExpanded((value) => !value)}
>
<AgentToolCallHeader call={call} expanded={expanded} hasDetail />
</button>
) : (
<div className="workspace-agents-status-panel__detail-tool-row-head">
<AgentToolCallHeader call={call} expanded={false} hasDetail={false} />
</div>
)}
{!hasDetail && call.summary ? (
<div className="workspace-agents-status-panel__detail-tool-summary">
{call.summary}
</div>
) : null}
{hasDetail && pinned ? (
<AgentExpandedToolContent call={call} onLinkClick={onLinkClick} />
) : null}
{hasDetail && !pinned ? (
<CollapsibleReveal expanded={expanded}>
<AgentExpandedToolContent call={call} onLinkClick={onLinkClick} />
</CollapsibleReveal>
) : null}
</div>
);
}
function taskCallAriaLabel(call: AgentToolCallCardProps["call"]): string {
return [
call.name,
call.status,
call.compactSummary?.trim() || call.summary.trim()
]
.filter(Boolean)
.join(" ");
}
@@ -0,0 +1,112 @@
import { useState, type JSX } from "react";
import type { AgentTaskStepVM } from "../contracts/agentTaskItemVM";
import type { AgentToolCallVM } from "../contracts/agentToolCallVM";
import { projectAgentTaskStepTool } from "../projection/agentTaskProjection";
import { AgentToolCallHeader } from "./AgentToolCallHeader";
import { AgentExpandedToolContent } from "./tool-renderers/AgentExpandedToolContent";
import { hasAgentToolContent } from "./tool-renderers/agentToolContentShared";
interface AgentTaskStepListProps {
steps: AgentTaskStepVM[];
onLinkClick?: (href: string) => void;
}
export function AgentTaskStepList({
steps,
onLinkClick
}: AgentTaskStepListProps): JSX.Element {
"use memo";
return (
<div className="workspace-agents-status-panel__detail-tool-stack">
{steps.map((step) => (
<AgentTaskStepRow key={step.id} step={step} onLinkClick={onLinkClick} />
))}
</div>
);
}
function AgentTaskStepRow({
step,
onLinkClick
}: {
step: AgentTaskStepVM;
onLinkClick?: (href: string) => void;
}): JSX.Element {
"use memo";
const [expanded, setExpanded] = useState(false);
const call = step.tool ?? projectTaskStepCall(step);
const hasDetail = hasAgentToolContent(call);
const ariaLabel = taskStepAriaLabel(call);
return (
<div className="workspace-agents-status-panel__detail-tool-row">
{hasDetail ? (
<button
type="button"
className="workspace-agents-status-panel__detail-tool-row-head workspace-agents-status-panel__detail-tool-row-head--button"
aria-expanded={expanded}
aria-label={ariaLabel}
onClick={() => setExpanded((value) => !value)}
>
<AgentToolCallHeader call={call} expanded={expanded} hasDetail />
</button>
) : (
<div className="workspace-agents-status-panel__detail-tool-row-head">
<AgentToolCallHeader call={call} expanded={false} hasDetail={false} />
</div>
)}
{!hasDetail && step.summary ? (
<div className="workspace-agents-status-panel__detail-tool-summary">
{step.summary}
</div>
) : null}
{hasDetail && expanded ? (
<AgentExpandedToolContent call={call} onLinkClick={onLinkClick} />
) : null}
</div>
);
}
function taskStepAriaLabel(call: AgentToolCallVM): string {
return [
call.name,
call.status,
call.compactSummary?.trim() || call.summary.trim()
]
.filter(Boolean)
.join(" ");
}
function projectTaskStepCall(step: AgentTaskStepVM): AgentToolCallVM {
return projectAgentTaskStepTool({
id: step.id,
turnId: step.turnId,
toolName: step.toolName,
name: step.name,
callType: null,
status: step.status,
summary: step.summary,
payload: null,
metadata: null,
input:
step.payload &&
typeof step.payload.input === "object" &&
step.payload.input !== null
? (step.payload.input as Record<string, unknown>)
: null,
output:
step.payload &&
typeof step.payload.output === "object" &&
step.payload.output !== null
? (step.payload.output as Record<string, unknown>)
: null,
error:
step.payload &&
typeof step.payload.error === "object" &&
step.payload.error !== null
? (step.payload.error as Record<string, unknown>)
: null,
locations: null,
occurredAtUnixMs: step.occurredAtUnixMs ?? null
});
}
@@ -0,0 +1,38 @@
import type { JSX } from "react";
import { WorkspaceAgentSessionThinkingDisclosure } from "../../WorkspaceAgentSessionThinkingDisclosure";
import type { AgentThinkingContentVM } from "../contracts/agentMessageRowVM";
import { RawTimelineJsonDisclosure } from "./RawTimelineJsonDisclosure";
interface AgentThinkingDisclosureProps {
thinking: AgentThinkingContentVM;
label: string;
onLinkClick?: (href: string) => void;
showRawTimelineJson?: boolean;
rawTimelineJsonLabel?: string;
}
export function AgentThinkingDisclosure({
thinking,
label,
onLinkClick,
showRawTimelineJson = false,
rawTimelineJsonLabel = ""
}: AgentThinkingDisclosureProps): JSX.Element {
"use memo";
return (
<>
<WorkspaceAgentSessionThinkingDisclosure
thinking={thinking}
label={label}
onLinkClick={onLinkClick}
/>
{showRawTimelineJson && rawTimelineJsonLabel ? (
<RawTimelineJsonDisclosure
items={thinking.sourceTimelineItems}
label={rawTimelineJsonLabel}
/>
) : null}
</>
);
}
@@ -0,0 +1,704 @@
import {
act,
fireEvent,
render,
screen,
waitFor
} from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { setAgentGuiI18nTestLocale } from "../../../i18n/testUtils";
import type { WorkspaceAgentSessionDetailToolCall } from "../../workspaceAgentSessionDetailViewModel";
import { projectAgentToolCall } from "../projection/agentToolProjection";
import { AgentAskUserQuestionCard } from "./AgentAskUserQuestionCard";
import { AgentEnterPlanModeCard } from "./AgentEnterPlanModeCard";
import { AgentExitPlanModeCard } from "./AgentExitPlanModeCard";
import { AgentTaskCallCard } from "./AgentTaskCallCard";
import { AgentToolCallCard } from "./AgentToolCallCard";
describe("Agent specialized tool cards", () => {
afterEach(async () => {
setAgentGuiI18nTestLocale("zh-CN");
});
it("expands waiting ask-user cards by default", async () => {
setAgentGuiI18nTestLocale("en");
render(
<AgentAskUserQuestionCard
call={projectAgentToolCall(
toolCall({
toolName: "AskUserQuestion",
callType: "interactive",
status: "waiting_input",
payload: {
input: {
questions: [
{
id: "scope",
question: "Which scope should we use?",
options: [
{ label: "Typed renderer", description: "Keep going" }
]
}
]
}
}
})
)}
/>
);
expect(screen.getByText("Questions")).toBeTruthy();
expect(screen.getByText("Which scope should we use?")).toBeTruthy();
expect(screen.queryByRole("button", { expanded: true })).toBeNull();
});
it("renders enter-plan cards as always-visible detail blocks", async () => {
setAgentGuiI18nTestLocale("en");
render(
<AgentEnterPlanModeCard
call={projectAgentToolCall(
toolCall({
toolName: "EnterPlanMode",
callType: "interactive",
payload: {
input: {
content: "Exploring codebase and planning the renderer rewrite."
}
}
})
)}
/>
);
expect(
screen.getByText("Exploring codebase and planning the renderer rewrite.")
).toBeTruthy();
expect(screen.queryByRole("button", { expanded: true })).toBeNull();
});
it("renders exit-plan cards as always-visible detail blocks", async () => {
setAgentGuiI18nTestLocale("en");
render(
<AgentExitPlanModeCard
call={projectAgentToolCall(
toolCall({
toolName: "ExitPlanMode",
callType: "interactive",
payload: {
input: {
plan: "1. Finalize the projection layer\\n2. Wire the shared renderer"
}
}
})
)}
/>
);
expect(screen.getByText(/Finalize the projection layer/)).toBeTruthy();
expect(screen.queryByRole("button", { expanded: true })).toBeNull();
});
it("renders running task cards as always-visible detail blocks", async () => {
setAgentGuiI18nTestLocale("en");
render(
<AgentTaskCallCard
call={projectAgentToolCall(
toolCall({
toolName: "Task",
callType: "subagent",
status: "running",
payload: {
metadata: {
status: "running",
steps: [
{
toolUseId: "step-1",
toolName: "Read",
status: "Completed",
toolInput: { path: "/workspace/demo/src/App.tsx" },
toolResult: { text: "loaded" }
}
]
}
}
})
)}
/>
);
expect(screen.getByText("Steps")).toBeTruthy();
expect(screen.queryByRole("button", { expanded: true })).toBeNull();
});
it("shows failed task status and fallback error when provider omits details", async () => {
setAgentGuiI18nTestLocale("en");
render(
<AgentTaskCallCard
defaultExpanded
call={projectAgentToolCall(
toolCall({
name: "Agent",
toolName: "Agent",
callType: "tool",
status: "Failed",
statusKind: null,
summary:
"Generate exactly one random integer from 1 to 10 inclusive.",
payload: {
input: {
prompt:
"Generate exactly one random integer from 1 to 10 inclusive."
}
}
})
)}
/>
);
expect(
screen.getByRole("button", { name: /Agent Failed Generate exactly/i })
).toBeTruthy();
expect(screen.getAllByText(/Failed/i).length).toBeGreaterThan(0);
expect(screen.getByText("Error")).toBeTruthy();
expect(
screen.getByText("The provider reported failure without details.")
).toBeTruthy();
expect(screen.queryByText("Output")).toBeNull();
});
it("keeps task step output hidden until the step expands", async () => {
setAgentGuiI18nTestLocale("en");
render(
<AgentTaskCallCard
defaultExpanded
call={projectAgentToolCall(
toolCall({
toolName: "Task",
callType: "subagent",
status: "completed",
payload: {
output: {
steps: [
{
toolUseId: "step-1",
toolName: "Read",
status: "Completed",
toolInput: { path: "/workspace/demo/README.md" },
toolResult: { output: "Loaded docs chunk" }
}
]
}
}
})
)}
/>
);
expect(screen.queryByText("Loaded docs chunk")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /Read Completed/i }));
expect(screen.getByText("Loaded docs chunk")).toBeTruthy();
});
it("shows diff stats in the tool call header for structured edit patches", async () => {
setAgentGuiI18nTestLocale("en");
render(
<AgentToolCallCard
call={projectAgentToolCall(
toolCall({
toolName: "Edit",
payload: {
output: {
structuredPatch: [
{
filePath: "src/app.ts",
diff: "diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1 @@\n-const ready = false\n+const ready = true\n"
}
]
}
}
})
)}
/>
);
expect(screen.getByText("+1")).toBeTruthy();
expect(screen.getByText("-1")).toBeTruthy();
});
it("does not show edit diff stats on approval tool headers", async () => {
setAgentGuiI18nTestLocale("en");
render(
<AgentToolCallCard
call={projectAgentToolCall(
toolCall({
name: "Approval",
toolName: "Approval",
callType: "approval",
status: "Completed",
statusKind: "completed",
payload: {
input: {
toolCall: {
input: {
file_path: "/workspace/generated.md",
old_string: "const ready = false",
new_string: "const ready = true"
},
name: "Edit",
title: "Edit",
toolName: "Edit"
}
}
}
})
)}
/>
);
expect(screen.queryByText("+1")).toBeNull();
expect(screen.queryByText("-1")).toBeNull();
});
it("uses explicit MCP target fields as the approval-wrapped tool title", async () => {
setAgentGuiI18nTestLocale("en");
render(
<AgentToolCallCard
call={projectAgentToolCall(
toolCall({
name: "Approval",
toolName: "Approval",
callType: "tool",
status: "Failed",
statusKind: "failed",
payload: {
input: {
requestId: "request-1",
server: "playwright",
tool: "browser_close",
options: [{ optionId: "approved", name: "Allow" }]
},
error: {
stdout: "Browser is already in use"
}
}
})
)}
/>
);
expect(screen.getByText("playwright / browser_close")).toBeTruthy();
expect(screen.queryByText("Approval")).toBeNull();
});
it("renders the image generation prompt without duplicating artifact output", async () => {
setAgentGuiI18nTestLocale("en");
const readFile = vi.fn().mockResolvedValue({
bytes: new Uint8Array([137, 80, 78, 71])
});
window.agentHostApi = {
...(window.agentHostApi ?? {}),
workspace: {
...(window.agentHostApi?.workspace ?? {}),
readFile
}
} as typeof window.agentHostApi;
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: vi.fn(() => "blob:tool-image-preview")
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: vi.fn()
});
render(
<AgentToolCallCard
defaultExpanded
call={projectAgentToolCall(
toolCall({
toolName: "ig_05eb62dbe723c910016a1336ad3de881919216a6f64051a5e2",
name: "ig_05eb62dbe723c910016a1336ad3de881919216a6f64051a5e2",
payload: {
input: {
prompt: "Revised prompt: a joyful little girl dancing"
},
output: {
savedPath: "/workspace/output/generated.png",
imageMimeType: "image/png"
}
}
})
)}
/>
);
expect(screen.getByText(/Revised prompt:/)).toBeTruthy();
expect(
screen.queryByRole("img", { name: "Image generation preview" })
).toBeNull();
expect(screen.queryByText("Output")).toBeNull();
expect(readFile).not.toHaveBeenCalled();
});
it("does not duplicate historical Codex image output from savedPath", async () => {
setAgentGuiI18nTestLocale("en");
const savedPath =
"/Users/demo/.tutti/agent/runs/session/codex-home/generated_images/thread/ig_legacy.png";
const readFile = vi.fn().mockResolvedValue({
bytes: new Uint8Array([137, 80, 78, 71])
});
window.agentHostApi = {
...(window.agentHostApi ?? {}),
workspace: {
...(window.agentHostApi?.workspace ?? {}),
readFile
}
} as typeof window.agentHostApi;
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: vi.fn(() => "blob:legacy-tool-image-preview")
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: vi.fn()
});
render(
<AgentToolCallCard
defaultExpanded
call={projectAgentToolCall(
toolCall({
toolName: "ImageGeneration",
name: "Generate image",
payload: {
output: {
savedPath,
result: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
}
}
})
)}
/>
);
expect(
screen.queryByRole("img", { name: "Image generation preview" })
).toBeNull();
expect(readFile).not.toHaveBeenCalled();
});
it("does not show an output loading placeholder for active image generation cards", async () => {
setAgentGuiI18nTestLocale("en");
const readFile = vi
.fn()
.mockRejectedValue(new Error("ENOENT: generated image not ready"));
window.agentHostApi = {
...(window.agentHostApi ?? {}),
workspace: {
...(window.agentHostApi?.workspace ?? {}),
readFile
}
} as typeof window.agentHostApi;
render(
<AgentToolCallCard
defaultExpanded
call={projectAgentToolCall(
toolCall({
toolName: "ig_05eb62dbe723c910016a1336ad3de881919216a6f64051a5e2",
name: "ig_05eb62dbe723c910016a1336ad3de881919216a6f64051a5e2",
status: "Running",
statusKind: "working",
payload: {
input: {
prompt: "Revised prompt: a joyful little girl dancing"
},
output: {
savedPath: "/workspace/output/generated.png",
imageMimeType: "image/png"
}
}
})
)}
/>
);
expect(screen.getByText(/Revised prompt:/)).toBeTruthy();
expect(screen.queryByText("Loading preview...")).toBeNull();
expect(
screen.queryByRole("img", { name: "Image generation preview" })
).toBeNull();
expect(readFile).not.toHaveBeenCalled();
});
it("does not render image-only output inside the tool call card", async () => {
setAgentGuiI18nTestLocale("en");
const readFile = vi.fn().mockResolvedValue({
bytes: new Uint8Array([137, 80, 78, 71])
});
window.agentHostApi = {
...(window.agentHostApi ?? {}),
workspace: {
...(window.agentHostApi?.workspace ?? {}),
readFile
}
} as typeof window.agentHostApi;
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: vi.fn(() => "blob:image-only-preview")
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: vi.fn()
});
render(
<AgentToolCallCard
defaultExpanded
call={projectAgentToolCall(
toolCall({
toolName: "image_generation",
payload: {
content: [
{
type: "content",
content: {
type: "image",
uri: "/workspace/output/generated-only.png",
mimeType: "image/png"
}
}
]
}
})
)}
/>
);
expect(
screen.queryByRole("img", { name: "Image generation preview" })
).toBeNull();
expect(screen.queryByText("Input")).toBeNull();
expect(screen.queryByText("Output")).toBeNull();
expect(readFile).not.toHaveBeenCalled();
});
it("does not allow expanding web fetch cards without displayable content", async () => {
setAgentGuiI18nTestLocale("en");
render(
<AgentToolCallCard
call={projectAgentToolCall(
toolCall({
name: "Fetch webpage",
toolName: "WebFetch",
status: "Running",
statusKind: "working"
})
)}
/>
);
expect(screen.getByText("Fetch webpage")).toBeTruthy();
expect(screen.getByText("running")).toBeTruthy();
expect(screen.queryByRole("button", { expanded: false })).toBeNull();
expect(screen.queryByRole("button", { name: /Fetch webpage/ })).toBeNull();
expect(
document.querySelector(".workspace-agents-status-panel__detail-tool-body")
).not.toBeInTheDocument();
});
it("does not allow expanding edit cards without displayable file content", async () => {
setAgentGuiI18nTestLocale("en");
render(
<AgentToolCallCard
call={projectAgentToolCall(
toolCall({
name: "Edit file",
toolName: "Edit",
status: "Completed",
statusKind: "completed"
})
)}
/>
);
expect(screen.getByText("Edit file")).toBeTruthy();
expect(screen.getByText("completed")).toBeTruthy();
expect(screen.queryByRole("button", { expanded: false })).toBeNull();
expect(
document.querySelector(".workspace-agents-status-panel__detail-tool-body")
).not.toBeInTheDocument();
});
it("shows added-line stats for write content without a diff patch", async () => {
setAgentGuiI18nTestLocale("en");
render(
<AgentToolCallCard
call={projectAgentToolCall(
toolCall({
toolName: "Write",
payload: {
input: {
file_path: "src/routes.ts",
content: "line one\nline two\n"
}
}
})
)}
/>
);
expect(screen.getByText("+2")).toBeTruthy();
});
it("hides diff stats for failed writes that still carry proposed content", async () => {
setAgentGuiI18nTestLocale("en");
render(
<AgentToolCallCard
call={projectAgentToolCall(
toolCall({
toolName: "Write",
status: "Failed",
statusKind: "failed",
payload: {
input: {
file_path: "/workspace/123.txt",
content: "{}"
},
error: {
message:
"<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>"
}
}
})
)}
/>
);
expect(screen.queryByText("+1")).toBeNull();
expect(screen.getByText("failed")).toBeTruthy();
});
it("keeps edit cards collapsed by default and expands detail on demand", async () => {
setAgentGuiI18nTestLocale("en");
render(
<AgentToolCallCard
call={projectAgentToolCall(
toolCall({
toolName: "Edit",
payload: {
output: {
detailedContent:
"diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1 @@\n-const ready = false\n+const ready = true\n"
}
}
})
)}
/>
);
const toggle = screen.getByRole("button", { expanded: false });
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(
document.querySelector(".workspace-agents-status-panel__detail-tool-body")
).not.toBeInTheDocument();
fireEvent.click(toggle);
await flushCollapsibleRevealFrames();
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("app.ts")).toBeTruthy();
const changedLine = screen.getByText("const ready = true");
expect(changedLine).toBeTruthy();
await waitFor(() =>
expect(changedLine.closest(".agent-collapsible-reveal")).toHaveAttribute(
"data-expanded",
"true"
)
);
});
it("keeps collapsed tool card details unmounted while idle", async () => {
setAgentGuiI18nTestLocale("en");
let idleCallback: IdleRequestCallback | null = null;
vi.stubGlobal("requestIdleCallback", (callback: IdleRequestCallback) => {
idleCallback = callback;
return 1;
});
vi.stubGlobal("cancelIdleCallback", () => undefined);
render(
<AgentToolCallCard
call={projectAgentToolCall(
toolCall({
toolName: "Edit",
payload: {
output: {
detailedContent:
"diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1 @@\n-const ready = false\n+const ready = true\n"
}
}
})
)}
/>
);
expect(screen.queryByText("@@ -1 +1 @@")).toBeNull();
act(() => {
idleCallback?.({
didTimeout: false,
timeRemaining: () => 50
});
});
expect(
document.querySelector(".workspace-agents-status-panel__detail-tool-body")
).not.toBeInTheDocument();
});
});
async function flushCollapsibleRevealFrames(): Promise<void> {
await flushAnimationFrame();
await flushAnimationFrame();
}
async function flushAnimationFrame(): Promise<void> {
await act(async () => {
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
});
}
function toolCall(
overrides: Partial<WorkspaceAgentSessionDetailToolCall> = {}
): WorkspaceAgentSessionDetailToolCall {
return {
id: "call:1",
name: "Tool",
toolName: null,
callType: "tool",
status: "Completed",
statusKind: "completed",
summary: "",
payload: null,
...overrides
};
}
@@ -0,0 +1,73 @@
import { useState, type JSX } from "react";
import type { AgentToolCallVM } from "../contracts/agentToolCallVM";
import { AgentToolCallHeader } from "./AgentToolCallHeader";
import { CollapsibleReveal } from "./CollapsibleReveal";
import { AgentExpandedToolContent } from "./tool-renderers/AgentExpandedToolContent";
import { hasAgentToolContent } from "./tool-renderers/agentToolContentShared";
export interface AgentToolCallCardProps {
call: AgentToolCallVM;
onLinkClick?: (href: string) => void;
defaultExpanded?: boolean;
nonCollapsible?: boolean;
variantClassName?: string;
}
export function AgentToolCallCard({
call,
onLinkClick,
defaultExpanded = false,
nonCollapsible = false,
variantClassName
}: AgentToolCallCardProps): JSX.Element {
"use memo";
const [expanded, setExpanded] = useState(defaultExpanded);
const hasDetail = hasAgentToolContent(call);
const canCollapse = hasDetail && !nonCollapsible;
const ariaLabel = toolCallAriaLabel(call);
return (
<div
className={[
"workspace-agents-status-panel__detail-tool-row",
variantClassName ?? ""
]
.filter(Boolean)
.join(" ")}
>
{canCollapse ? (
<button
type="button"
className="workspace-agents-status-panel__detail-tool-row-head workspace-agents-status-panel__detail-tool-row-head--button"
aria-expanded={expanded}
aria-label={ariaLabel}
onClick={() => setExpanded((value) => !value)}
>
<AgentToolCallHeader call={call} expanded={expanded} hasDetail />
</button>
) : (
<div className="workspace-agents-status-panel__detail-tool-row-head">
<AgentToolCallHeader call={call} expanded={false} hasDetail={false} />
</div>
)}
{hasDetail && nonCollapsible ? (
<AgentExpandedToolContent call={call} onLinkClick={onLinkClick} />
) : null}
{hasDetail && !nonCollapsible ? (
<CollapsibleReveal expanded={expanded}>
<AgentExpandedToolContent call={call} onLinkClick={onLinkClick} />
</CollapsibleReveal>
) : null}
</div>
);
}
function toolCallAriaLabel(call: AgentToolCallVM): string {
return [
call.name,
call.status,
call.compactSummary?.trim() || call.summary.trim()
]
.filter(Boolean)
.join(" ");
}
@@ -0,0 +1,299 @@
import type { JSX } from "react";
import { AlertCircle, ChevronDown, ChevronRight } from "lucide-react";
import { FilePenLineIcon } from "../../../app/renderer/components/icons/FilePenLineIcon";
import { FileTextIcon } from "../../../app/renderer/components/icons/FileTextIcon";
import { HammerIcon } from "../../../app/renderer/components/icons/HammerIcon";
import { ToolsLinedIcon } from "../../../app/renderer/components/icons/ToolsLinedIcon";
import { WriteLinedIcon } from "../../../app/renderer/components/icons/WriteLinedIcon";
import { ToolNameIcon } from "../../toolActivityKindIcons";
import { isImageGenerationToolCall } from "../../imageGenerationTool";
import type { AgentToolCallVM } from "../contracts/agentToolCallVM";
import {
getFileChangeRenderData,
isFailedToolCall
} from "./tool-renderers/render-data/agentToolRenderData";
const TOOL_ROW_ICON_SIZE = 16;
interface AgentToolCallHeaderProps {
call: AgentToolCallVM;
expanded: boolean;
hasDetail: boolean;
}
export function AgentToolCallHeader({
call,
expanded,
hasDetail
}: AgentToolCallHeaderProps): JSX.Element {
"use memo";
const diffStats = diffStatsForCall(call);
const isActive =
call.statusKind === "working" || call.statusKind === "waiting";
const isFailed = isFailedToolCall(call);
return (
<div
data-active={isActive ? "true" : undefined}
className={[
"workspace-agents-status-panel__detail-tool-row-header-content",
isActive ? "tsh-inline-scanlight-group" : ""
]
.filter(Boolean)
.join(" ")}
>
<div className="workspace-agents-status-panel__detail-tool-row-icon tsh-inline-scanlight-icon">
{isFailed ? (
<AlertCircle
size={TOOL_ROW_ICON_SIZE}
strokeWidth={2}
aria-hidden="true"
/>
) : isActive && isFileReadTool(call.toolName) ? (
<FileTextIcon
size={TOOL_ROW_ICON_SIZE}
active
aria-hidden="true"
className="text-current"
data-testid="agent-tool-read-loading-icon"
/>
) : isActive && isFileWriteTool(call.toolName) ? (
<FilePenLineIcon
size={TOOL_ROW_ICON_SIZE}
active
aria-hidden="true"
className="text-current"
data-testid="agent-tool-write-loading-icon"
/>
) : isActive && isFileEditTool(call.toolName) ? (
<FileTextIcon
size={TOOL_ROW_ICON_SIZE}
active
aria-hidden="true"
className="text-current"
data-testid="agent-tool-edit-loading-icon"
/>
) : isActive && isStdinWriteTool(call.toolName) ? (
<WriteLinedIcon
width={TOOL_ROW_ICON_SIZE}
height={TOOL_ROW_ICON_SIZE}
aria-hidden="true"
className="text-current"
data-testid="agent-tool-stdin-write-icon"
/>
) : isImageGenerationToolCall({
toolName: call.toolName,
displayName: call.name
}) ? (
<ToolNameIcon
toolName={call.toolName ?? call.name}
width={TOOL_ROW_ICON_SIZE}
height={TOOL_ROW_ICON_SIZE}
aria-hidden="true"
className="text-current"
data-testid={
isActive
? "agent-tool-image-generation-loading-icon"
: "agent-tool-image-generation-icon"
}
/>
) : isWebTool(call.toolName) ? (
<ToolNameIcon
toolName={call.toolName}
width={TOOL_ROW_ICON_SIZE}
height={TOOL_ROW_ICON_SIZE}
aria-hidden="true"
className="text-current"
data-testid={
isActive ? "agent-tool-web-loading-icon" : "agent-tool-web-icon"
}
/>
) : isFileReadTool(call.toolName) ? (
<ToolNameIcon
toolName={call.toolName}
width={TOOL_ROW_ICON_SIZE}
height={TOOL_ROW_ICON_SIZE}
aria-hidden="true"
className="text-current"
data-testid="agent-tool-read-icon"
/>
) : call.statusKind === "working" || call.statusKind === "waiting" ? (
<HammerIcon
size={TOOL_ROW_ICON_SIZE}
active
aria-hidden="true"
className="text-current"
data-testid="agent-tool-call-loading-icon"
/>
) : (
<ToolsLinedIcon
width={TOOL_ROW_ICON_SIZE}
height={TOOL_ROW_ICON_SIZE}
aria-hidden="true"
/>
)}
</div>
<div
className={[
"workspace-agents-status-panel__detail-tool-row-text",
isActive ? "tsh-inline-scanlight-line" : ""
]
.filter(Boolean)
.join(" ")}
>
<strong className="workspace-agents-status-panel__detail-tool-row-title">
{formatInlineTitleLabel(call.name)}
</strong>
{call.status ? (
<span className="workspace-agents-status-panel__detail-tool-status">
{formatInlineStatusLabel(call.status)}
</span>
) : null}
{isActive ? <LoadingEllipsis /> : null}
{diffStats ? (
<span className="workspace-agents-status-panel__detail-tool-diff-stats">
{diffStats.added > 0 ? (
<span className="workspace-agents-status-panel__detail-tool-diff-added">
+{diffStats.added}
</span>
) : null}
{diffStats.removed > 0 ? (
<span className="workspace-agents-status-panel__detail-tool-diff-removed">
-{diffStats.removed}
</span>
) : null}
</span>
) : null}
</div>
{hasDetail ? (
expanded ? (
<ChevronDown
size={12}
strokeWidth={2.2}
aria-hidden="true"
className="workspace-agents-status-panel__detail-tool-row-chevron"
/>
) : (
<ChevronRight
size={12}
strokeWidth={2.2}
aria-hidden="true"
className="workspace-agents-status-panel__detail-tool-row-chevron"
/>
)
) : null}
</div>
);
}
function formatInlineStatusLabel(label: string): string {
return /^[A-Z][a-z]+$/.test(label) ? label.toLowerCase() : label;
}
function formatInlineTitleLabel(label: string): string {
const trimmed = label.trim();
if (!/[a-z]/.test(trimmed) || !/[A-Z]/.test(trimmed.slice(1))) {
return label;
}
const words = trimmed.split(/(\s+)/);
let changed = false;
const normalized = words
.map((word, index) => {
if (index === 0 || !/^[A-Z][a-z]+$/.test(word)) {
return word;
}
changed = true;
return word.toLowerCase();
})
.join("");
return changed ? normalized : label;
}
function LoadingEllipsis(): JSX.Element {
"use memo";
return (
<span className="tsh-inline-loading-ellipsis" aria-hidden="true">
<span />
<span />
<span />
</span>
);
}
function diffStatsForCall(
call: AgentToolCallVM
): { added: number; removed: number } | null {
if (call.rendererKind === "approval" || isFailedToolCall(call)) {
return null;
}
const files = getFileChangeRenderData(call);
if (files.length > 0) {
const totals = files.reduce(
(accumulator, file) => ({
added: accumulator.added + file.added,
removed: accumulator.removed + file.removed
}),
{ added: 0, removed: 0 }
);
if (totals.added > 0 || totals.removed > 0) {
return totals;
}
}
return null;
}
function isFileReadTool(toolName: string | null): boolean {
switch (normalizeToolName(toolName)) {
case "read":
case "readfile":
case "listfiles":
case "findfiles":
case "glob":
return true;
default:
return false;
}
}
function isFileEditTool(toolName: string | null): boolean {
switch (normalizeToolName(toolName)) {
case "edit":
case "editfile":
case "multiedit":
return true;
default:
return false;
}
}
function isFileWriteTool(toolName: string | null): boolean {
switch (normalizeToolName(toolName)) {
case "write":
case "writefile":
return true;
default:
return false;
}
}
function isStdinWriteTool(toolName: string | null): boolean {
return normalizeToolName(toolName) === "writestdin";
}
function isWebTool(toolName: string | null): boolean {
switch (normalizeToolName(toolName)) {
case "websearch":
case "webfetch":
return true;
default:
return false;
}
}
function normalizeToolName(value: string | null): string {
return (value ?? "")
.trim()
.replace(/[_\s-]+/g, "")
.toLowerCase();
}
@@ -0,0 +1,305 @@
import {
act,
fireEvent,
render,
screen,
waitFor
} from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { setAgentGuiI18nTestLocale } from "../../../i18n/testUtils";
import type { WorkspaceAgentSessionDetailToolCall } from "../../workspaceAgentSessionDetailViewModel";
import type { AgentToolCallVM } from "../contracts/agentToolCallVM";
import type { AgentToolGroupRowVM } from "../contracts/agentToolGroupRowVM";
import { projectAgentToolCall } from "../projection/agentToolProjection";
import { AgentToolGroupRow } from "./AgentToolGroupRow";
describe("AgentToolGroupRow", () => {
afterEach(async () => {
setAgentGuiI18nTestLocale("zh-CN");
});
it("renders approval timeline calls as collapsible tool cards", async () => {
setAgentGuiI18nTestLocale("en");
render(
<AgentToolGroupRow
row={{
kind: "tool-group",
id: "row-1",
turnId: "turn-1",
grouped: false,
calls: [
projectAgentToolCall(
detailToolCall({
id: "call:approval-1",
name: "Edit /workspace/index.html",
toolName: "Edit",
callType: "approval",
status: "waiting_approval",
payload: {
input: {
requestId: "approval-request-1",
options: [{ optionId: "approved", label: "Approve once" }],
toolCall: {
kind: "edit",
title: "Edit /workspace/index.html"
}
}
}
})
)
],
entries: [],
occurredAtUnixMs: 1
}}
label={(count) => `Tool calls (${count})`}
thinkingLabel="Thinking"
/>
);
expect(screen.getByRole("button", { expanded: false })).toHaveAttribute(
"aria-expanded",
"false"
);
});
it("renders the task glyph before grouped tool call labels", async () => {
render(
<AgentToolGroupRow
row={toolGroupRow()}
label={(count) => `Tool calls (${count})`}
thinkingLabel="Thought process"
/>
);
const button = screen.getByRole("button", { name: "Tool calls (2)" });
const icon = button.querySelector(
".workspace-agents-status-panel__detail-tool-count-icon"
);
const chevron = button.querySelector(
".workspace-agents-status-panel__detail-tool-count-chevron"
);
expect(icon).toBeInTheDocument();
expect(icon).toHaveAttribute("width", "16");
expect(icon).toHaveAttribute("height", "16");
expect(icon?.querySelector("path")?.getAttribute("d")).toContain(
"M8.29297 14.793"
);
expect(chevron).toBeInTheDocument();
fireEvent.click(button);
await flushCollapsibleRevealFrames();
const list = document.querySelector(
".workspace-agents-status-panel__detail-tool-list"
);
await waitFor(() =>
expect(list?.closest(".agent-collapsible-reveal")).toHaveAttribute(
"data-expanded",
"true"
)
);
expect(
button.closest(".workspace-agents-status-panel__detail-tool-section")
).toHaveAttribute("data-tool-group-expanded", "true");
expect(
document.querySelectorAll(
".workspace-agents-status-panel__detail-tool-list-item"
)
).toHaveLength(2);
});
it("keeps inner tool cards collapsed until their own buttons are clicked", async () => {
const calls = [
toolCall({ id: "call:1", output: { text: "first output" } }),
toolCall({ id: "call:2", output: { text: "second output" } })
];
render(
<AgentToolGroupRow
row={{
...toolGroupRow(),
calls,
entries: calls.map((call) => ({ kind: "tool-call", call }))
}}
label={(count) => `Tool calls (${count})`}
thinkingLabel="Thought process"
/>
);
fireEvent.click(screen.getByRole("button", { name: "Tool calls (2)" }));
await flushCollapsibleRevealFrames();
const innerToggles = document.querySelectorAll(
"button.workspace-agents-status-panel__detail-tool-row-head--button"
);
expect(innerToggles).toHaveLength(2);
expect(
[...innerToggles].every(
(toggle) => toggle.getAttribute("aria-expanded") === "false"
)
).toBe(true);
});
it("does not render grouped file-change summaries under the tool-call count", () => {
render(
<AgentToolGroupRow
row={{ ...toolGroupRow(), summary: "Changed App.tsx and 1 more files" }}
label={(count) => `Tool calls (${count})`}
thinkingLabel="Thought process"
/>
);
expect(screen.queryByText("Changed App.tsx and 1 more files")).toBeNull();
});
it("renders raw timeline JSON disclosure for debug session views", () => {
render(
<AgentToolGroupRow
row={{
kind: "tool-group",
id: "row-raw",
turnId: "turn:1",
grouped: false,
calls: [
toolCall({
sourceTimelineItems: [
{
id: 42,
workspaceId: "room-1",
agentSessionId: "session-1",
eventId: "tool-started",
actorType: "agent",
actorId: "agent",
itemType: "call",
callType: "tool",
callId: "call:1",
name: "Read",
status: "started",
occurredAtUnixMs: 1,
createdAtUnixMs: 1
}
]
})
],
entries: [],
occurredAtUnixMs: 1
}}
label={(count) => `Tool calls (${count})`}
thinkingLabel="Thought process"
showRawTimelineJson
rawTimelineJsonLabel="Raw JSON"
/>
);
fireEvent.click(screen.getByRole("button", { name: "Raw JSON" }));
expect(screen.getByText(/"eventId": "tool-started"/)).toBeInTheDocument();
});
it("keeps collapsed grouped tool details unmounted while idle", () => {
let idleCallback: IdleRequestCallback | null = null;
vi.stubGlobal("requestIdleCallback", (callback: IdleRequestCallback) => {
idleCallback = callback;
return 1;
});
vi.stubGlobal("cancelIdleCallback", () => undefined);
render(
<AgentToolGroupRow
row={toolGroupRow()}
label={(count) => `Tool calls (${count})`}
thinkingLabel="Thought process"
/>
);
expect(
document.querySelector(".workspace-agents-status-panel__detail-tool-list")
).toBeNull();
act(() => {
idleCallback?.({
didTimeout: false,
timeRemaining: () => 50
});
});
expect(
document.querySelector(".workspace-agents-status-panel__detail-tool-list")
).toBeNull();
});
});
async function flushCollapsibleRevealFrames(): Promise<void> {
await flushAnimationFrame();
await flushAnimationFrame();
}
async function flushAnimationFrame(): Promise<void> {
await act(async () => {
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
});
}
function toolGroupRow(): AgentToolGroupRowVM {
const calls = [
toolCall({ id: "call:1", name: "Read file", summary: "README.md" }),
toolCall({ id: "call:2", name: "Edit file", summary: "src/App.tsx" })
];
return {
kind: "tool-group",
id: "group:1",
turnId: "turn:1",
grouped: true,
calls,
summary: null,
entries: calls.map((call) => ({ kind: "tool-call", call })),
occurredAtUnixMs: null
};
}
function toolCall(overrides: Partial<AgentToolCallVM> = {}): AgentToolCallVM {
return {
kind: "tool-call",
id: "call:1",
turnId: "turn:1",
name: "Tool",
toolName: "tool",
callType: "tool",
status: "Completed",
statusKind: "completed",
summary: "",
compactSummary: null,
payload: null,
input: null,
output: null,
error: null,
metadata: null,
locations: null,
rendererKind: "default",
approval: null,
planMode: null,
askUserQuestion: null,
task: null,
occurredAtUnixMs: null,
...overrides
};
}
function detailToolCall(
overrides: Partial<WorkspaceAgentSessionDetailToolCall> = {}
): WorkspaceAgentSessionDetailToolCall {
return {
id: "call:1",
name: "Tool",
toolName: null,
callType: "tool",
status: "Completed",
statusKind: "completed",
summary: "",
payload: null,
...overrides
};
}
@@ -0,0 +1,200 @@
import { memo, useState, type JSX } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { ToolActivityKindIcon } from "../../toolActivityKindIcons";
import type { AgentToolCallVM } from "../contracts/agentToolCallVM";
import type { AgentToolGroupRowVM } from "../contracts/agentToolGroupRowVM";
import { AgentAskUserQuestionCard } from "./AgentAskUserQuestionCard";
import { CollapsibleReveal } from "./CollapsibleReveal";
import { AgentEnterPlanModeCard } from "./AgentEnterPlanModeCard";
import { AgentExitPlanModeCard } from "./AgentExitPlanModeCard";
import { AgentThinkingDisclosure } from "./AgentThinkingDisclosure";
import { AgentTaskCallCard } from "./AgentTaskCallCard";
import { AgentSubAgentCards } from "./AgentSubAgentCards";
import { AgentToolCallCard } from "./AgentToolCallCard";
import { RawTimelineJsonDisclosure } from "./RawTimelineJsonDisclosure";
interface AgentToolGroupRowProps {
row: AgentToolGroupRowVM;
label: (count: number) => string;
thinkingLabel: string;
onLinkClick?: (href: string) => void;
expanded?: boolean;
expansionKey?: string;
onExpandedChange?: (key: string, expanded: boolean) => void;
showRawTimelineJson?: boolean;
rawTimelineJsonLabel?: string;
}
export const AgentToolGroupRow = memo(function AgentToolGroupRow({
row,
label,
thinkingLabel,
onLinkClick,
expanded,
expansionKey,
onExpandedChange,
showRawTimelineJson = false,
rawTimelineJsonLabel = ""
}: AgentToolGroupRowProps): JSX.Element {
"use memo";
const [localExpanded, setLocalExpanded] = useState(false);
const hasDetail = row.entries.length > 0;
const isExpanded = hasDetail && (expanded ?? localExpanded);
const setNextExpanded = (nextExpanded: boolean) => {
if (expanded === undefined) {
setLocalExpanded(nextExpanded);
}
if (expansionKey) {
onExpandedChange?.(expansionKey, nextExpanded);
}
};
const groupLabel = label(row.calls.length);
const singleCall = row.calls[0];
if (!row.grouped && singleCall) {
return renderToolCard(
singleCall,
onLinkClick,
showRawTimelineJson,
rawTimelineJsonLabel
);
}
return (
<div
className="workspace-agents-status-panel__detail-tool-section"
data-tool-group-expanded={isExpanded ? "true" : undefined}
>
{hasDetail ? (
<button
type="button"
className="workspace-agents-status-panel__detail-tool-count"
aria-expanded={isExpanded}
aria-label={groupLabel}
onClick={() => setNextExpanded(!isExpanded)}
>
<ToolGroupLabel label={groupLabel} />
{isExpanded ? (
<ChevronDown
size={12}
strokeWidth={2.2}
aria-hidden="true"
className="workspace-agents-status-panel__detail-tool-count-chevron"
/>
) : (
<ChevronRight
size={12}
strokeWidth={2.2}
aria-hidden="true"
className="workspace-agents-status-panel__detail-tool-count-chevron"
/>
)}
</button>
) : (
<div className="workspace-agents-status-panel__detail-tool-count">
<ToolGroupLabel label={groupLabel} />
</div>
)}
{hasDetail ? (
<CollapsibleReveal expanded={isExpanded}>
<div className="workspace-agents-status-panel__detail-tool-list">
{row.entries.map((entry) =>
entry.kind === "thinking" ? (
<div
key={entry.thinking.id}
className="workspace-agents-status-panel__detail-tool-list-item workspace-agents-status-panel__detail-tool-row"
>
<AgentThinkingDisclosure
thinking={entry.thinking}
label={thinkingLabel}
onLinkClick={onLinkClick}
showRawTimelineJson={showRawTimelineJson}
rawTimelineJsonLabel={rawTimelineJsonLabel}
/>
</div>
) : (
<div
key={entry.call.id}
className="workspace-agents-status-panel__detail-tool-list-item"
>
{renderToolCard(
entry.call,
onLinkClick,
showRawTimelineJson,
rawTimelineJsonLabel
)}
</div>
)
)}
</div>
</CollapsibleReveal>
) : null}
</div>
);
});
function ToolGroupLabel({ label }: { label: string }): JSX.Element {
"use memo";
return (
<>
<ToolActivityKindIcon
kind="update_todos"
width={16}
height={16}
className="workspace-agents-status-panel__detail-tool-count-icon"
/>
<span className="workspace-agents-status-panel__detail-tool-count-label">
{renderToolCountLabel(label)}
</span>
</>
);
}
function renderToolCountLabel(label: string): JSX.Element {
return (
<span className="workspace-agents-status-panel__detail-tool-count-primary">
{label}
</span>
);
}
function renderToolCard(
call: AgentToolCallVM,
onLinkClick?: (href: string) => void,
showRawTimelineJson = false,
rawTimelineJsonLabel = ""
): JSX.Element {
const props = { call, onLinkClick };
// A delegated sub-agent renders as its own first-class card - no tool-row
// chrome, no prompt echo; identity/status belong to the sub-agent itself.
if (call.task?.subAgents?.length) {
return <AgentSubAgentCards call={call} onLinkClick={onLinkClick} />;
}
let card: JSX.Element;
switch (call.rendererKind) {
case "plan-enter":
card = <AgentEnterPlanModeCard {...props} />;
break;
case "plan-exit":
card = <AgentExitPlanModeCard {...props} />;
break;
case "ask-user":
card = <AgentAskUserQuestionCard {...props} />;
break;
case "task":
card = <AgentTaskCallCard {...props} />;
break;
default:
card = <AgentToolCallCard {...props} />;
}
return (
<>
{card}
{showRawTimelineJson && rawTimelineJsonLabel ? (
<RawTimelineJsonDisclosure
items={call.sourceTimelineItems}
label={rawTimelineJsonLabel}
/>
) : null}
</>
);
}
@@ -0,0 +1,147 @@
import { memo, useCallback, type JSX, type ReactNode } from "react";
import type { WorkspaceLinkAction } from "../../../contexts/workspace/presentation/renderer/actions/workspaceLinkActions";
import type { AgentMessageMarkdownWorkspaceAppIcon } from "../../AgentMessageMarkdown";
import type { AgentGUIProviderSkillOption } from "../../../agent-gui/agentGuiNode/model/agentGuiNodeTypes";
import { resolveAgentConversationLinkAction } from "../actions/agentConversationLinkActions";
import type { AgentTranscriptRowVM } from "../contracts/agentTranscriptRowVM";
import type { AgentConversationParticipantPresentation } from "../contracts/agentConversationParticipantPresentation";
import { AgentGeneratedImageRow } from "./AgentGeneratedImageRow";
import { AgentGoalControlRow } from "./AgentGoalControlRow";
import { AgentMessageBlock } from "./AgentMessageBlock";
import { AgentProcessingRow } from "./AgentProcessingRow";
import { AgentToolGroupRow } from "./AgentToolGroupRow";
import { AgentTurnSummaryRow } from "./AgentTurnSummaryRow";
import type { AgentUserMessageEditRetryControl } from "./AgentUserMessageEditRetry";
interface AgentTranscriptItemViewProps {
workspaceRoot: string | null;
basePath: string;
row: AgentTranscriptRowVM;
editRetry?: AgentUserMessageEditRetryControl;
labels: {
toolCallsLabel: (count: number) => string;
thinkingLabel: string;
processing: string;
turnSummary: string;
rawTimelineJson?: string;
};
onLinkAction?: (action: WorkspaceLinkAction) => void;
onAuthLogin?: (provider?: string | null) => void;
provider?: string | null;
availableSkills?: readonly AgentGUIProviderSkillOption[];
workspaceAppIcons?: readonly AgentMessageMarkdownWorkspaceAppIcon[];
showRawTimelineJson?: boolean;
participantPresentation?: AgentConversationParticipantPresentation;
showParticipantHeader?: boolean;
isActiveTurn?: boolean;
processingPaused?: boolean;
toolGroupExpanded?: boolean;
toolGroupExpansionKey?: string;
onToolGroupExpandedChange?: (key: string, expanded: boolean) => void;
footerAction?: ReactNode;
}
export const AgentTranscriptItemView = memo(function AgentTranscriptItemView({
workspaceRoot,
basePath,
row,
editRetry,
labels,
onLinkAction,
onAuthLogin,
provider,
availableSkills,
workspaceAppIcons,
showRawTimelineJson = false,
participantPresentation,
showParticipantHeader,
isActiveTurn = false,
processingPaused = false,
toolGroupExpanded,
toolGroupExpansionKey,
onToolGroupExpandedChange,
footerAction
}: AgentTranscriptItemViewProps): JSX.Element {
"use memo";
const handleLinkClick = useCallback(
(href: string) => {
const action = resolveAgentConversationLinkAction({
workspaceRoot,
basePath,
href,
source: "agent-markdown"
});
if (action) {
onLinkAction?.(action);
}
},
[basePath, onLinkAction, workspaceRoot]
);
switch (row.kind) {
case "generated-image":
return <AgentGeneratedImageRow row={row} />;
case "goal-control":
return (
<AgentGoalControlRow
row={row}
availableSkills={availableSkills}
workspaceAppIcons={workspaceAppIcons}
/>
);
case "message":
return (
<AgentMessageBlock
workspaceRoot={workspaceRoot}
basePath={basePath}
row={row}
editRetry={editRetry}
onLinkAction={onLinkAction}
onAuthLogin={onAuthLogin}
provider={provider}
availableSkills={availableSkills}
workspaceAppIcons={workspaceAppIcons}
thinkingLabel={labels.thinkingLabel}
toolCallsLabel={labels.toolCallsLabel}
showRawTimelineJson={showRawTimelineJson}
rawTimelineJsonLabel={labels.rawTimelineJson}
participantPresentation={participantPresentation}
showParticipantHeader={showParticipantHeader}
isActiveTurn={isActiveTurn}
footerAction={footerAction}
/>
);
case "tool-group":
return (
<AgentToolGroupRow
row={row}
label={labels.toolCallsLabel}
thinkingLabel={labels.thinkingLabel}
onLinkClick={handleLinkClick}
showRawTimelineJson={showRawTimelineJson}
rawTimelineJsonLabel={labels.rawTimelineJson}
expanded={row.grouped ? toolGroupExpanded : undefined}
onExpandedChange={row.grouped ? onToolGroupExpandedChange : undefined}
expansionKey={toolGroupExpansionKey}
/>
);
case "turn-summary":
return (
<AgentTurnSummaryRow
row={row}
workspaceRoot={workspaceRoot}
basePath={basePath}
label={labels.turnSummary}
onLinkAction={onLinkAction}
/>
);
case "processing":
return (
<AgentProcessingRow
row={row}
label={labels.processing}
paused={processingPaused}
/>
);
}
});
@@ -0,0 +1,33 @@
export function AgentTranscriptSkeleton({
label,
testId = "agent-gui-transcript-loading-skeleton"
}: {
label: string;
testId?: string;
}): React.JSX.Element {
"use memo";
return (
<div
className="agent-gui-node__transcript-skeleton"
role="status"
aria-label={label}
data-testid={testId}
>
<div className="agent-gui-node__transcript-skeleton-row agent-gui-node__transcript-skeleton-row-user">
<span className="agent-gui-node__transcript-skeleton-bubble agent-gui-node__transcript-skeleton-line-medium" />
</div>
<div className="agent-gui-node__transcript-skeleton-row agent-gui-node__transcript-skeleton-row-assistant">
<span className="agent-gui-node__transcript-skeleton-line agent-gui-node__transcript-skeleton-line-long" />
<span className="agent-gui-node__transcript-skeleton-line agent-gui-node__transcript-skeleton-line-medium" />
<span className="agent-gui-node__transcript-skeleton-line agent-gui-node__transcript-skeleton-line-short" />
</div>
<div className="agent-gui-node__transcript-skeleton-row agent-gui-node__transcript-skeleton-row-user">
<span className="agent-gui-node__transcript-skeleton-bubble agent-gui-node__transcript-skeleton-line-short" />
</div>
<div className="agent-gui-node__transcript-skeleton-row agent-gui-node__transcript-skeleton-row-assistant">
<span className="agent-gui-node__transcript-skeleton-line agent-gui-node__transcript-skeleton-line-medium" />
<span className="agent-gui-node__transcript-skeleton-line agent-gui-node__transcript-skeleton-line-long" />
</div>
</div>
);
}
@@ -0,0 +1,796 @@
import {
Fragment,
memo,
useCallback,
useLayoutEffect,
useMemo,
useState,
type JSX,
type ReactNode,
type Ref
} from "react";
import { providerForkBindingAllowsAttempt } from "@tutti-os/agent-activity-core";
import type { WorkspaceLinkAction } from "../../../contexts/workspace/presentation/renderer/actions/workspaceLinkActions";
import type { AgentMessageMarkdownWorkspaceAppIcon } from "../../AgentMessageMarkdown";
import type { AgentGUIProviderSkillOption } from "../../../agent-gui/agentGuiNode/model/agentGuiNodeTypes";
import type { AgentConversationVM } from "../contracts/agentConversationVM";
import type { AgentConversationParticipantPresentation } from "../contracts/agentConversationParticipantPresentation";
import type { AgentConversationFollowEndMode } from "../agentConversationFollowEndController";
import { AgentTranscriptItemView } from "./AgentTranscriptItemView";
import {
AgentForkThroughTurnButton,
AgentForkThroughTurnFooter
} from "./AgentForkThroughTurnButton";
import { useAgentTurnDisclosureStore } from "./AgentTurnDisclosureContext";
import { AgentTurnWorkSection } from "./AgentTurnWorkSection";
import {
buildAgentTurnWorkSectionModel,
findParticipantHeaderRenderKeys
} from "./agentTurnWorkSectionModel";
import { assessAgentTranscriptComplexity } from "./agentTranscriptComplexity";
import { useTurnDisclosureMotion } from "./useTurnDisclosureMotion";
import {
AgentMessageLocatorRail,
findMessageLocatorScrollParent,
scrollTranscriptRowIntoView
} from "./AgentMessageLocatorRail";
import {
buildAgentTranscriptTurnGroups,
buildTurnGroupIndexByRowIndex,
buildUserMessageLocatorItems,
escapeCssString,
findLastMessageRowIndex,
findTurnDividerRowIndexes,
isAssistantParticipantContentRow,
transcriptRowKey,
useAgentTranscriptDisplayRows,
useEnteringTranscriptRows,
type AgentMessageLocatorItem
} from "./agentTranscriptModel";
import {
AgentTranscriptAttachmentView,
useAgentTranscriptTurnAttachments,
type AgentTranscriptAttachmentLocator,
type AgentTranscriptTurnAttachment
} from "./useAgentTranscriptTurnAttachments";
import {
AGENT_TRANSCRIPT_ESTIMATED_TURN_HEIGHT_PX,
useAgentTranscriptVirtualizer,
type AgentTranscriptVirtualScrollController
} from "./useAgentTranscriptVirtualizer";
import {
editRetryControlsEqual,
type AgentTranscriptEditRetryControl,
useAgentTranscriptEditRetryProjection
} from "./useAgentTranscriptEditRetryProjection";
import { useAgentObservationGap } from "../AgentObservationGapContext";
const AGENT_TRANSCRIPT_DISCLOSURE_TURN_GAP_PX = 24;
const AGENT_TRANSCRIPT_LEGACY_TURN_GAP_PX = 12;
const AGENT_TRANSCRIPT_FALLBACK_TURN_COUNT = 3;
export type {
AgentTranscriptAttachmentLocator,
AgentTranscriptTurnAttachment
} from "./useAgentTranscriptTurnAttachments";
export type { AgentTranscriptVirtualScrollController } from "./useAgentTranscriptVirtualizer";
export interface AgentTranscriptViewProps {
conversation: AgentConversationVM;
isVisible?: boolean;
editRetry?: AgentTranscriptEditRetryControl;
turnAttachments?: readonly AgentTranscriptTurnAttachment[];
turnAttachmentLocatorRef?: Ref<AgentTranscriptAttachmentLocator>;
onTurnAttachmentVisibilityChange?: (
attachmentId: string,
visible: boolean
) => void;
onLinkAction?: (action: WorkspaceLinkAction) => void;
onAuthLogin?: (provider?: string | null) => void;
onForkThroughTurn?: (turnId: string) => void;
availableSkills?: readonly AgentGUIProviderSkillOption[];
workspaceAppIcons?: readonly AgentMessageMarkdownWorkspaceAppIcon[];
showRawTimelineJson?: boolean;
participantPresentation?: AgentConversationParticipantPresentation;
followEndMode?: AgentConversationFollowEndMode;
forkThroughTurnPendingTurnIds?: readonly string[];
virtualListLayoutRevision?: number;
virtualScrollControllerRef?: Ref<AgentTranscriptVirtualScrollController>;
labels: {
toolCallsLabel: (count: number) => string;
thinkingLabel: string;
processing: string;
turnSummary: string;
rawTimelineJson?: string;
userMessageLocator?: string;
};
}
function participantPresentationEqual(
previous: AgentConversationParticipantPresentation | undefined,
next: AgentConversationParticipantPresentation | undefined
): boolean {
if (previous === next) {
return true;
}
if ((!previous || !previous.enabled) && (!next || !next.enabled)) {
return true;
}
if (!previous?.enabled || !next?.enabled) {
return false;
}
if (previous.status !== next.status) {
return false;
}
if (previous.status === "loading" || next.status === "loading") {
return true;
}
return (
previous.user.name === next.user.name &&
previous.user.avatarUrl === next.user.avatarUrl &&
previous.agent.name === next.agent.name &&
previous.agent.avatarUrl === next.agent.avatarUrl
);
}
function transcriptLabelsEqual(
previous: AgentTranscriptViewProps["labels"],
next: AgentTranscriptViewProps["labels"]
): boolean {
return (
previous === next ||
(previous.thinkingLabel === next.thinkingLabel &&
previous.processing === next.processing &&
previous.turnSummary === next.turnSummary &&
previous.rawTimelineJson === next.rawTimelineJson &&
previous.userMessageLocator === next.userMessageLocator &&
previous.toolCallsLabel === next.toolCallsLabel)
);
}
function transcriptTurnIdentityEquals(
previous: AgentConversationVM["sourceDetail"]["turns"],
next: AgentConversationVM["sourceDetail"]["turns"]
): boolean {
return (
previous === next ||
(previous.length === next.length &&
previous.every((turn, index) => turn.id === next[index]?.id))
);
}
function transcriptCanonicalTurnsEqual(
previous: AgentConversationVM["sourceDetail"]["sessionTurns"],
next: AgentConversationVM["sourceDetail"]["sessionTurns"]
): boolean {
return (
previous === next ||
(previous?.length === next?.length &&
(previous?.every((turn, index) => {
const nextTurn = next?.[index];
return (
turn.turnId === nextTurn?.turnId &&
turn.phase === nextTurn.phase &&
turn.outcome === nextTurn.outcome &&
turn.providerForkBindingAvailable ===
nextTurn.providerForkBindingAvailable &&
turn.providerForkBindingState === nextTurn.providerForkBindingState &&
turn.startedAtUnixMs === nextTurn.startedAtUnixMs &&
turn.settledAtUnixMs === nextTurn.settledAtUnixMs
);
}) ??
true))
);
}
function transcriptConversationRenderInputEquals(
previous: AgentConversationVM,
next: AgentConversationVM
): boolean {
return (
previous === next ||
(previous.rows === next.rows &&
previous.workspaceRoot === next.workspaceRoot &&
previous.sourceDetail.session.agentSessionId ===
next.sourceDetail.session.agentSessionId &&
previous.sourceDetail.session.activeTurnId ===
next.sourceDetail.session.activeTurnId &&
previous.sourceDetail.session.activeTurn?.turnId ===
next.sourceDetail.session.activeTurn?.turnId &&
previous.sourceDetail.session.activeTurn?.phase ===
next.sourceDetail.session.activeTurn?.phase &&
previous.sourceDetail.session.imported ===
next.sourceDetail.session.imported &&
previous.sourceDetail.session.kind === next.sourceDetail.session.kind &&
previous.sourceDetail.session.lifecycleCapabilities.forkThroughTurn ===
next.sourceDetail.session.lifecycleCapabilities.forkThroughTurn &&
previous.sourceDetail.session.pendingInteractions ===
next.sourceDetail.session.pendingInteractions &&
previous.sourceDetail.cwd === next.sourceDetail.cwd &&
transcriptTurnIdentityEquals(
previous.sourceDetail.turns,
next.sourceDetail.turns
) &&
transcriptCanonicalTurnsEqual(
previous.sourceDetail.sessionTurns,
next.sourceDetail.sessionTurns
))
);
}
export function areAgentTranscriptViewPropsEqual(
previous: AgentTranscriptViewProps,
next: AgentTranscriptViewProps
): boolean {
return (
transcriptConversationRenderInputEquals(
previous.conversation,
next.conversation
) &&
(previous.isVisible ?? true) === (next.isVisible ?? true) &&
previous.onLinkAction === next.onLinkAction &&
previous.onAuthLogin === next.onAuthLogin &&
previous.onForkThroughTurn === next.onForkThroughTurn &&
previous.forkThroughTurnPendingTurnIds ===
next.forkThroughTurnPendingTurnIds &&
previous.availableSkills === next.availableSkills &&
previous.workspaceAppIcons === next.workspaceAppIcons &&
previous.turnAttachments === next.turnAttachments &&
previous.turnAttachmentLocatorRef === next.turnAttachmentLocatorRef &&
previous.onTurnAttachmentVisibilityChange ===
next.onTurnAttachmentVisibilityChange &&
editRetryControlsEqual(previous.editRetry, next.editRetry) &&
previous.showRawTimelineJson === next.showRawTimelineJson &&
previous.followEndMode === next.followEndMode &&
previous.virtualListLayoutRevision === next.virtualListLayoutRevision &&
previous.virtualScrollControllerRef === next.virtualScrollControllerRef &&
participantPresentationEqual(
previous.participantPresentation,
next.participantPresentation
) &&
transcriptLabelsEqual(previous.labels, next.labels)
);
}
export const AgentTranscriptView = memo(function AgentTranscriptView({
conversation,
isVisible = true,
editRetry,
turnAttachments = [],
turnAttachmentLocatorRef,
onTurnAttachmentVisibilityChange,
onLinkAction,
onAuthLogin,
onForkThroughTurn,
availableSkills,
workspaceAppIcons,
showRawTimelineJson = false,
participantPresentation,
followEndMode,
forkThroughTurnPendingTurnIds = [],
virtualListLayoutRevision = 0,
virtualScrollControllerRef,
labels
}: AgentTranscriptViewProps): JSX.Element {
"use memo";
const [expandedToolRows, setExpandedToolRows] = useState<
Record<string, boolean>
>({});
const [hasMovingTurnDisclosure, handleDisclosureMotionChange] =
useTurnDisclosureMotion();
const turnDisclosureStore = useAgentTurnDisclosureStore();
const [virtualScrollElement, setVirtualScrollElement] =
useState<HTMLElement | null>(null);
const [
virtualListOffsetFromScrollOrigin,
setVirtualListOffsetFromScrollOrigin
] = useState(0);
const agentSessionId = conversation.sourceDetail.session.agentSessionId;
const activeTurnId = conversation.sourceDetail.session.activeTurnId;
const observationGap = useAgentObservationGap(agentSessionId, activeTurnId);
const participantHeadersEnabled = participantPresentation?.enabled === true;
// Participant-header presentation (Agent board session detail): tool-group
// rows attach to the assistant message that follows them instead of sitting
// after the previous message, and presentation turns key off user messages.
// Canonical Turn groups remain responsible for disclosure and timing. The
// row projection lives in the transcript model read hook so this component
// stays within the degradation-check memo budget.
const transcriptRowSet = useAgentTranscriptDisplayRows(
conversation.rows,
participantHeadersEnabled
);
const displayRows = transcriptRowSet.rows;
const rowKeys = transcriptRowSet.rowKeys;
const { editableUserMessageRowId, scopedEditRetry } =
useAgentTranscriptEditRetryProjection(
displayRows,
conversation.sourceDetail.session.agentSessionId,
editRetry
);
const participantTurnProjection = transcriptRowSet.participantTurnProjection;
const turnGroups = useMemo(
() => buildAgentTranscriptTurnGroups(displayRows, rowKeys),
[displayRows, rowKeys]
);
const turnGroupIndexByRowIndex = useMemo(
() => buildTurnGroupIndexByRowIndex(turnGroups),
[turnGroups]
);
const userMessageLocatorItems = useMemo(
() =>
buildUserMessageLocatorItems(
displayRows,
rowKeys,
turnGroupIndexByRowIndex
),
[displayRows, rowKeys, turnGroupIndexByRowIndex]
);
const enteringRowKeys = useEnteringTranscriptRows(rowKeys);
const handleToolGroupExpandedChange = useCallback(
(key: string, expanded: boolean) => {
setExpandedToolRows((previous) => {
if (previous[key] === expanded) {
return previous;
}
return {
...previous,
[key]: expanded
};
});
},
[]
);
const turnIndexById = useMemo(
() =>
new Map(
conversation.sourceDetail.turns.map((turn, index) => [turn.id, index])
),
[conversation.sourceDetail.turns]
);
const dividerRowIndexes = useMemo(
() =>
participantTurnProjection
? participantTurnProjection.dividerRowIndexes
: findTurnDividerRowIndexes(turnIndexById, displayRows),
[displayRows, turnIndexById, participantTurnProjection]
);
const canonicalTurnById = new Map(
(conversation.sourceDetail.sessionTurns ?? []).map((turn) => [
turn.turnId,
turn
])
);
const turnWorkSectionModelByKey = new Map(
turnGroups.map((group) => {
const isActiveTurn =
group.turnId !== null &&
group.turnId === conversation.sourceDetail.session.activeTurnId;
return [
group.key,
buildAgentTurnWorkSectionModel(
group,
group.turnId ? (canonicalTurnById.get(group.turnId) ?? null) : null,
isActiveTurn,
{
collapseIntermediateAssistantReplies:
!conversation.sourceDetail.session.imported,
liveFrozenAtUnixMs:
group.turnId !== null && group.turnId === activeTurnId
? observationGap?.startedAtUnixMs
: null,
liveObservationGapPresentationState:
group.turnId !== null && group.turnId === activeTurnId
? observationGap?.presentationState
: null
}
)
] as const;
})
);
const participantHeaderRenderKeys = participantTurnProjection
? findParticipantHeaderRenderKeys(
turnGroups,
rowKeys,
turnWorkSectionModelByKey,
participantTurnProjection.turnIndexByRowIndex
)
: null;
const basePath = conversation.sourceDetail.cwd;
const workspaceRoot = conversation.workspaceRoot;
const provider = conversation.activity.agentProvider;
const shouldVirtualize = useMemo(
() => assessAgentTranscriptComplexity(turnGroups).shouldVirtualize,
[turnGroups]
);
const { rowVirtualizer, setVirtualizerHostElement, virtualizerHostRef } =
useAgentTranscriptVirtualizer({
agentSessionId,
followEndMode,
hasMovingTurnDisclosure,
scrollElement: virtualScrollElement,
scrollMargin: virtualListOffsetFromScrollOrigin,
shouldVirtualize,
turnGroups,
virtualScrollControllerRef
});
const attachmentProjection = useAgentTranscriptTurnAttachments({
attachments: turnAttachments,
locatorRef: turnAttachmentLocatorRef,
onVisibilityChange: onTurnAttachmentVisibilityChange,
rowVirtualizer,
shouldVirtualize,
turnGroups,
virtualizerHostRef
});
const handleLocateUserMessage = useCallback(
(item: AgentMessageLocatorItem) => {
const scrollParent = virtualizerHostRef.current
? findMessageLocatorScrollParent(virtualizerHostRef.current)
: null;
const scrollToRenderedRow = (): boolean => {
const renderedRow = (
scrollParent ?? document
).querySelector<HTMLElement>(
`[data-agent-transcript-row="${escapeCssString(item.rowKey)}"]`
);
if (!renderedRow) {
return false;
}
scrollTranscriptRowIntoView(
renderedRow,
scrollParent ?? findMessageLocatorScrollParent(renderedRow)
);
return true;
};
if (scrollToRenderedRow()) {
return;
}
if (shouldVirtualize) {
rowVirtualizer.scrollToIndex(item.turnGroupIndex, {
align: "center"
});
window.setTimeout(scrollToRenderedRow, 0);
}
},
[rowVirtualizer, shouldVirtualize]
);
useLayoutEffect(() => {
if (!isVisible || !shouldVirtualize) {
return;
}
const virtualizerHost = virtualizerHostRef.current;
const scrollElement = virtualizerHost
? findMessageLocatorScrollParent(virtualizerHost)
: null;
setVirtualScrollElement(scrollElement);
if (!virtualizerHost || !scrollElement) {
setVirtualListOffsetFromScrollOrigin(0);
return;
}
const nextOffset = Math.max(
0,
virtualizerHost.getBoundingClientRect().top -
scrollElement.getBoundingClientRect().top +
scrollElement.scrollTop
);
setVirtualListOffsetFromScrollOrigin((previousOffset) =>
previousOffset === nextOffset ? previousOffset : nextOffset
);
}, [isVisible, shouldVirtualize, virtualListLayoutRevision]);
const renderRow = (
row: AgentConversationVM["rows"][number],
rowIndex: number,
renderKey?: string,
footerAction?: ReactNode
): JSX.Element => {
const rowKey =
renderKey ??
(displayRows[rowIndex] === row
? (rowKeys[rowIndex] ?? transcriptRowKey(row))
: transcriptRowKey(row));
const shouldAnimateEnter =
row.kind !== "processing" && enteringRowKeys.has(rowKey);
const showParticipantHeader =
participantHeaderRenderKeys?.has(rowKey) ?? false;
const participantContent =
participantHeadersEnabled &&
!showParticipantHeader &&
isAssistantParticipantContentRow(row)
? "assistant"
: undefined;
const activeTurn = conversation.sourceDetail.session.activeTurn;
const canonicalTurn =
row.turnId === null ? null : (canonicalTurnById.get(row.turnId) ?? null);
const isActiveTurn =
row.turnId !== null &&
canonicalTurn?.phase !== "settled" &&
(activeTurn?.turnId === row.turnId
? activeTurn.phase !== "settled"
: conversation.sourceDetail.session.activeTurnId === row.turnId);
return (
<div
key={rowKey}
className="agent-gui-transcript-row"
data-agent-transcript-row={rowKey}
data-agent-transcript-row-kind={row.kind}
data-agent-transcript-row-speaker={
row.kind === "message" ? row.speaker : undefined
}
data-agent-transcript-row-thinking-first={
row.kind === "message" &&
row.speaker === "assistant" &&
row.thinking.length > 0
? "true"
: undefined
}
data-agent-transcript-row-thinking-last={
row.kind === "message" &&
row.speaker === "assistant" &&
row.thinking.length > 0 &&
row.messages.length === 0
? "true"
: undefined
}
data-agent-transcript-row-index={rowIndex}
data-agent-transcript-row-participant-content={participantContent}
data-agent-transcript-row-enter={
shouldAnimateEnter ? "true" : undefined
}
>
<AgentTranscriptItemView
workspaceRoot={workspaceRoot}
basePath={basePath}
row={row}
editRetry={
row.id === editableUserMessageRowId ? scopedEditRetry : undefined
}
labels={labels}
onLinkAction={onLinkAction}
onAuthLogin={onAuthLogin}
provider={provider}
availableSkills={availableSkills}
workspaceAppIcons={workspaceAppIcons}
showRawTimelineJson={showRawTimelineJson}
participantPresentation={participantPresentation}
showParticipantHeader={showParticipantHeader}
isActiveTurn={isActiveTurn}
processingPaused={
isActiveTurn &&
row.turnId !== null &&
row.turnId === activeTurnId &&
observationGap !== null
}
toolGroupExpanded={
row.kind === "tool-group"
? expandedToolRows[rowKey] === true
: undefined
}
toolGroupExpansionKey={row.kind === "tool-group" ? rowKey : undefined}
onToolGroupExpandedChange={handleToolGroupExpandedChange}
footerAction={footerAction}
/>
</div>
);
};
const resolveForkThroughTurnAction = (
turnId: string | null
): { disabled: boolean; pending: boolean; turnId: string } | null => {
const lifecycleCapabilities =
conversation.sourceDetail.session.lifecycleCapabilities;
const canonicalTurn =
turnId === null ? null : (canonicalTurnById.get(turnId) ?? null);
if (
!turnId ||
!onForkThroughTurn ||
conversation.sourceDetail.session.kind !== "root" ||
lifecycleCapabilities.forkThroughTurn !== true ||
!canonicalTurn ||
canonicalTurn.phase !== "settled" ||
!providerForkBindingAllowsAttempt(canonicalTurn)
) {
return null;
}
const pending = forkThroughTurnPendingTurnIds.includes(turnId);
return {
disabled: pending,
pending,
turnId
};
};
const renderLegacyTurnGroup = (
group: (typeof turnGroups)[number]
): JSX.Element => {
const forkAction = resolveForkThroughTurnAction(group.turnId);
const forkButton = forkAction ? (
<AgentForkThroughTurnButton
disabled={forkAction.disabled}
pending={forkAction.pending}
onFork={() => onForkThroughTurn?.(forkAction.turnId)}
/>
) : null;
const footerRowIndex = findLastMessageRowIndex(group.rows);
return (
<Fragment key={group.key}>
{group.rows.map(({ row, rowIndex }) => {
const rowKey = rowKeys[rowIndex] ?? transcriptRowKey(row);
return (
<Fragment key={rowKey}>
{dividerRowIndexes.has(rowIndex) ? (
<div
className="h-px w-full flex-none bg-[var(--line-2,var(--tutti-line-2))]"
data-testid="agent-transcript-turn-divider"
aria-hidden="true"
/>
) : null}
{renderRow(
row,
rowIndex,
undefined,
rowIndex === footerRowIndex ? forkButton : null
)}
</Fragment>
);
})}
{forkAction && footerRowIndex === null ? (
<AgentForkThroughTurnFooter
disabled={forkAction.disabled}
pending={forkAction.pending}
onFork={() => onForkThroughTurn?.(forkAction.turnId)}
/>
) : null}
</Fragment>
);
};
const renderTurnGroup = (group: (typeof turnGroups)[number]): JSX.Element => {
const model = turnWorkSectionModelByKey.get(group.key) ?? null;
if (!model) {
return renderLegacyTurnGroup(group);
}
const forkAction = resolveForkThroughTurnAction(group.turnId);
const forkButton = forkAction ? (
<AgentForkThroughTurnButton
disabled={forkAction.disabled}
pending={forkAction.pending}
onFork={() => onForkThroughTurn?.(forkAction.turnId)}
/>
) : null;
const footerRowIndex = findLastMessageRowIndex(group.rows);
return (
<AgentTurnWorkSection
key={group.key}
model={model}
sessionId={conversation.sourceDetail.session.agentSessionId}
turnKey={group.turnId ?? group.key}
showDivider={group.rows.some(({ rowIndex }) =>
dividerRowIndexes.has(rowIndex)
)}
disclosureStore={turnDisclosureStore}
onDisclosureMotionChange={handleDisclosureMotionChange}
renderRow={(row, rowIndex, renderKey) =>
renderRow(
row,
rowIndex,
renderKey,
rowIndex === footerRowIndex ? forkButton : null
)
}
footer={
forkAction && footerRowIndex === null ? (
<AgentForkThroughTurnFooter
disabled={forkAction.disabled}
pending={forkAction.pending}
onFork={() => onForkThroughTurn?.(forkAction.turnId)}
/>
) : null
}
/>
);
};
const renderAttachment = (
attachment: AgentTranscriptTurnAttachment
): JSX.Element => (
<AgentTranscriptAttachmentView
key={attachment.id}
attachment={attachment}
onElementChange={attachmentProjection.onElementChange}
/>
);
if (shouldVirtualize) {
const usesFallbackVirtualItems = virtualScrollElement === null;
const fallbackStartIndex = Math.max(
0,
turnGroups.length - AGENT_TRANSCRIPT_FALLBACK_TURN_COUNT
);
const virtualItems = usesFallbackVirtualItems
? turnGroups
.slice(-AGENT_TRANSCRIPT_FALLBACK_TURN_COUNT)
.map((group, fallbackIndex) => ({
index: fallbackStartIndex + fallbackIndex,
key: group.key,
start:
(fallbackStartIndex + fallbackIndex) *
AGENT_TRANSCRIPT_ESTIMATED_TURN_HEIGHT_PX
}))
: rowVirtualizer.getVirtualItems();
return (
<>
<AgentMessageLocatorRail
followEndMode={followEndMode}
items={userMessageLocatorItems}
isVisible={isVisible}
label={labels.userMessageLocator}
onLocate={handleLocateUserMessage}
virtualSelectionSource={rowVirtualizer}
/>
<div
ref={setVirtualizerHostElement}
className="agent-gui-transcript-virtual"
data-agent-transcript-virtualized="true"
>
{virtualItems.map((virtualTurn) => {
const group = turnGroups[virtualTurn.index];
if (!group) {
return null;
}
return (
<div
key={virtualTurn.key}
ref={rowVirtualizer.measureElement}
className="agent-gui-transcript-virtual-item"
data-index={virtualTurn.index}
data-agent-transcript-virtual-turn={group.key}
style={{
paddingBottom: `${
turnWorkSectionModelByKey.get(group.key)
? AGENT_TRANSCRIPT_DISCLOSURE_TURN_GAP_PX
: AGENT_TRANSCRIPT_LEGACY_TURN_GAP_PX
}px`,
...(usesFallbackVirtualItems
? {
transform: `translateY(${virtualTurn.start}px)`
}
: {})
}}
>
{renderTurnGroup(group)}
{attachmentProjection.byGroupIndex
.get(virtualTurn.index)
?.map(renderAttachment)}
</div>
);
})}
</div>
{attachmentProjection.trailing.map(renderAttachment)}
</>
);
}
return (
<>
<AgentMessageLocatorRail
followEndMode={followEndMode}
items={userMessageLocatorItems}
isVisible={isVisible}
label={labels.userMessageLocator}
onLocate={handleLocateUserMessage}
/>
{turnGroups.map((group, groupIndex) => (
<Fragment key={group.key}>
{renderTurnGroup(group)}
{attachmentProjection.byGroupIndex
.get(groupIndex)
?.map(renderAttachment)}
</Fragment>
))}
{attachmentProjection.trailing.map(renderAttachment)}
</>
);
}, areAgentTranscriptViewPropsEqual);
@@ -0,0 +1,51 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type JSX,
type ReactNode
} from "react";
export interface AgentTurnDisclosureStore {
expandedOverrides: Readonly<Record<string, boolean>>;
setExpandedOverride: (key: string, expanded: boolean) => void;
}
const AgentTurnDisclosureContext =
createContext<AgentTurnDisclosureStore | null>(null);
export function AgentTurnDisclosureProvider({
children
}: {
children: ReactNode;
}): JSX.Element {
const store = useAgentTurnDisclosureStoreState();
return (
<AgentTurnDisclosureContext.Provider value={store}>
{children}
</AgentTurnDisclosureContext.Provider>
);
}
export function useAgentTurnDisclosureStore(): AgentTurnDisclosureStore {
const contextStore = useContext(AgentTurnDisclosureContext);
const localStore = useAgentTurnDisclosureStoreState();
return contextStore ?? localStore;
}
function useAgentTurnDisclosureStoreState(): AgentTurnDisclosureStore {
const [expandedOverrides, setExpandedOverrides] = useState<
Record<string, boolean>
>({});
const setExpandedOverride = useCallback((key: string, expanded: boolean) => {
setExpandedOverrides((previous) =>
previous[key] === expanded ? previous : { ...previous, [key]: expanded }
);
}, []);
return useMemo(
() => ({ expandedOverrides, setExpandedOverride }),
[expandedOverrides, setExpandedOverride]
);
}
@@ -0,0 +1,734 @@
import { useEffect, useMemo, useState, type JSX, type MouseEvent } from "react";
import {
ChevronDown,
ChevronRight,
LoaderCircle,
Redo2,
Undo2
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
toast
} from "@tutti-os/ui-system";
import { DirectLinedIcon } from "../../../app/renderer/components/icons/DirectLinedIcon";
import { translate } from "../../../i18n/index";
import { useOptionalAgentHostApi } from "../../../agentActivityHost";
import type { WorkspaceLinkAction } from "../../../contexts/workspace/presentation/renderer/actions/workspaceLinkActions";
import { resolveWorkspaceFileLinkAction } from "../../../contexts/workspace/presentation/renderer/actions/workspaceLinkActions";
import { CanvasNodeGhostIconButton } from "../../../contexts/workspace/presentation/renderer/components/shared/CanvasNodeGhostIconButton";
import type {
AgentTurnSummaryFileVM,
AgentTurnSummaryPatchBatchVM,
AgentTurnSummaryRowVM
} from "../contracts/agentTurnSummaryRowVM";
import { buildAgentTurnSummaryPatchDiff } from "../rules/agentTurnSummaryPatchDiff";
import {
fileCanBuildPatch,
patchBatchDirectoryCwd,
resolvePatchDiffCwd,
resolvePatchExecutionCwd
} from "../rules/agentTurnSummaryPatchRuntime";
import { AgentCodeBlock } from "./tool-renderers/code/AgentCodeBlock";
import { CollapsibleReveal } from "./CollapsibleReveal";
import { AgentMonacoDiffViewer } from "./tool-renderers/file-diff/AgentMonacoDiffViewer";
import {
agentFileChangeStats,
isAgentUnifiedDiff
} from "./tool-renderers/file-diff/agentUnifiedDiff";
import { AgentUnifiedPatchViewer } from "./tool-renderers/file-diff/AgentUnifiedPatchViewer";
interface AgentTurnSummaryRowProps {
row: AgentTurnSummaryRowVM;
workspaceRoot?: string | null;
basePath?: string | null;
label: string;
onLinkAction?: (action: WorkspaceLinkAction) => void;
}
type PatchSupportState =
| { key: string; status: "checking" }
| { key: string; status: "supported" }
| { key: string; status: "unsupported" };
export function AgentTurnSummaryRow({
row,
workspaceRoot,
basePath,
onLinkAction
}: AgentTurnSummaryRowProps): JSX.Element {
"use memo";
const [expandedFiles, setExpandedFiles] = useState<Record<string, boolean>>(
{}
);
const [showAllFiles, setShowAllFiles] = useState(false);
const [patchAction, setPatchAction] = useState<"undo" | "reapply">("undo");
const [patchPending, setPatchPending] = useState(false);
const [patchSupportState, setPatchSupportState] =
useState<PatchSupportState | null>(null);
const agentHostApi = useOptionalAgentHostApi();
const aggregateStats = useMemo(
() => summarizeRowDiff(row.files),
[row.files]
);
const patchBatches = useMemo(() => {
const canBuildEveryFileFallback = row.files.every(fileCanBuildPatch);
const fileFallbackBatches: AgentTurnSummaryPatchBatchVM[] =
canBuildEveryFileFallback
? row.files.map((file) => ({
changes: [
{
path: file.path,
changeType: file.changeType,
unifiedDiff: file.unifiedDiff,
oldString: file.oldString ?? null,
newString: file.newString ?? null,
content: file.content ?? null
}
],
cwd: fallbackPatchFileCwd(file.path, workspaceRoot),
toolCallId: `${row.id}:${file.messageId}:unified-diff`
}))
: [];
const sourceBatches = row.patchBatches?.length
? row.patchBatches
: fileFallbackBatches;
if (!hasCompletePatchCoverage(row.files, sourceBatches)) {
return [];
}
return buildExecutablePatchBatches(sourceBatches, workspaceRoot);
}, [row.files, row.id, row.patchBatches, workspaceRoot]);
const canRenderPatchButton = Boolean(
agentHostApi?.workspace.applyGitPatch && row.files.length > 0
);
const canApplyPatch = Boolean(
agentHostApi?.workspace.applyGitPatch && patchBatches.length > 0
);
const patchSupportCwds = useMemo(
() => Array.from(new Set(patchBatches.map((batch) => batch.cwd))).sort(),
[patchBatches]
);
const patchSupportKey = patchSupportCwds.join("\n");
const resolveGitPatchSupport = agentHostApi?.workspace.resolveGitPatchSupport;
useEffect(() => {
if (!resolveGitPatchSupport || patchSupportCwds.length === 0) {
setPatchSupportState(null);
return;
}
let disposed = false;
setPatchSupportState({ key: patchSupportKey, status: "checking" });
void (async () => {
try {
for (const cwd of patchSupportCwds) {
const result = await resolveGitPatchSupport({ cwd });
if (!result.supported) {
if (!disposed) {
setPatchSupportState({
key: patchSupportKey,
status: "unsupported"
});
}
return;
}
}
if (!disposed) {
setPatchSupportState({ key: patchSupportKey, status: "supported" });
}
} catch {
if (!disposed) {
setPatchSupportState({ key: patchSupportKey, status: "supported" });
}
}
})();
return () => {
disposed = true;
};
}, [patchSupportCwds, patchSupportKey, resolveGitPatchSupport]);
const visibleFiles = row.files.slice(0, 3);
const hiddenFiles = row.files.slice(3);
const hiddenFileCount = hiddenFiles.length;
const patchActionLabel =
patchAction === "undo"
? translate("agentHost.agentGui.turnSummaryUndo")
: translate("agentHost.agentGui.turnSummaryReapply");
const isPatchSupportChecking = Boolean(
resolveGitPatchSupport &&
patchSupportCwds.length > 0 &&
(patchSupportState?.key !== patchSupportKey ||
patchSupportState.status === "checking")
);
const isPatchUnsupported = Boolean(
patchSupportState?.key === patchSupportKey &&
patchSupportState.status === "unsupported"
);
const patchDisabledReason = isPatchUnsupported
? translate("agentHost.agentGui.turnSummaryGitRequired")
: isPatchSupportChecking
? translate("agentHost.agentGui.turnSummaryCheckingGit")
: !canApplyPatch
? translate("agentHost.agentGui.turnSummaryPatchUnavailable")
: null;
const isPatchActionDisabled = Boolean(
patchPending ||
isPatchSupportChecking ||
isPatchUnsupported ||
!canApplyPatch
);
const handlePatchAction = async (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (!agentHostApi?.workspace.applyGitPatch || isPatchActionDisabled) {
return;
}
const orderedBatches =
patchAction === "undo" ? [...patchBatches].reverse() : patchBatches;
if (orderedBatches.length === 0) {
return;
}
setPatchPending(true);
let changed = false;
const failureMessage =
patchAction === "undo"
? translate("agentHost.agentGui.turnSummaryUndoFailed")
: translate("agentHost.agentGui.turnSummaryReapplyFailed");
try {
for (const batch of orderedBatches) {
const result = await agentHostApi.workspace.applyGitPatch({
cwd: batch.cwd,
diff: batch.diff,
revert: patchAction === "undo"
});
if (
result.status === "success" ||
result.appliedPaths.length > 0 ||
result.conflictedPaths.length > 0
) {
changed = true;
}
if (result.status !== "success") {
showPatchFailureToast(
agentHostApi,
patchFailureMessage(result.errorCode, failureMessage)
);
return;
}
}
setPatchAction((current) => (current === "undo" ? "reapply" : "undo"));
} catch {
showPatchFailureToast(agentHostApi, failureMessage);
} finally {
setPatchPending(false);
if (changed) {
window.dispatchEvent(new CustomEvent("tutti-agent-git-patch-applied"));
}
}
};
return (
<section className="workspace-agents-status-panel__detail-turn-summary">
<div className="agent-turn-summary-card w-full overflow-hidden rounded-[8px] text-[var(--text-primary)]">
<div className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0 text-[15px] font-semibold leading-5 tracking-[0] text-[var(--text-primary)]">
{translate("agentHost.agentGui.turnSummaryFilesChanged", {
count: row.fileCount
})}
</div>
<div className="inline-flex shrink-0 items-center gap-2.5 text-[11px] font-semibold">
{aggregateStats.added > 0 ? (
<span className="workspace-agents-status-panel__detail-tool-diff-added">
+{aggregateStats.added}
</span>
) : null}
{aggregateStats.removed > 0 ? (
<span className="workspace-agents-status-panel__detail-tool-diff-removed">
-{aggregateStats.removed}
</span>
) : null}
</div>
</div>
</div>
{canRenderPatchButton ? (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<span
className={`inline-flex shrink-0 ${
isPatchActionDisabled ? "cursor-not-allowed" : ""
}`}
>
<CanvasNodeGhostIconButton
aria-label={patchActionLabel}
className="w-auto min-w-0 gap-1.5 px-2 text-[12px] font-medium leading-4 disabled:pointer-events-none"
disabled={isPatchActionDisabled}
onClick={handlePatchAction}
>
{patchPending || isPatchSupportChecking ? (
<LoaderCircle
width={14}
height={14}
aria-hidden="true"
className="animate-spin text-[var(--text-secondary)]"
/>
) : patchAction === "undo" ? (
<Undo2
width={14}
height={14}
aria-hidden="true"
className="text-[var(--text-secondary)]"
/>
) : (
<Redo2
width={14}
height={14}
aria-hidden="true"
className="text-[var(--text-secondary)]"
/>
)}
<span>{patchActionLabel}</span>
</CanvasNodeGhostIconButton>
</span>
</TooltipTrigger>
{patchDisabledReason ? (
<TooltipContent className="max-w-[260px] whitespace-normal text-left">
{patchDisabledReason}
</TooltipContent>
) : null}
</Tooltip>
</TooltipProvider>
) : null}
</div>
<div className="agent-turn-summary-card__list">
{visibleFiles.map((file) => {
const key = `${file.path}:${file.messageId}`;
return (
<TurnSummaryFileCard
key={key}
file={file}
workspaceRoot={workspaceRoot}
basePath={basePath}
expanded={Boolean(expandedFiles[key])}
onLinkAction={onLinkAction}
onToggle={() =>
setExpandedFiles((current) => ({
...current,
[key]: !current[key]
}))
}
/>
);
})}
{hiddenFileCount > 0 ? (
<CollapsibleReveal expanded={showAllFiles} preMountOnIdle>
<div className="agent-turn-summary-card__overflow-list">
{hiddenFiles.map((file) => {
const key = `${file.path}:${file.messageId}`;
return (
<TurnSummaryFileCard
key={key}
file={file}
workspaceRoot={workspaceRoot}
basePath={basePath}
expanded={Boolean(expandedFiles[key])}
onLinkAction={onLinkAction}
onToggle={() =>
setExpandedFiles((current) => ({
...current,
[key]: !current[key]
}))
}
/>
);
})}
</div>
</CollapsibleReveal>
) : null}
{hiddenFileCount > 0 && !showAllFiles ? (
<button
type="button"
className="agent-turn-summary-card__toggle-more flex w-full items-center gap-2 px-4 py-2.5 text-left text-[11px] font-semibold transition-colors"
onClick={() => setShowAllFiles(true)}
>
<span>
{translate("agentHost.agentGui.turnSummaryShowMoreFiles", {
count: hiddenFileCount
})}
</span>
<ChevronDown size={16} strokeWidth={2.2} aria-hidden="true" />
</button>
) : hiddenFileCount > 0 ? (
<button
type="button"
className="agent-turn-summary-card__toggle-more flex w-full items-center gap-2 px-4 py-2.5 text-left text-[11px] font-semibold transition-colors"
onClick={() => setShowAllFiles(false)}
>
<span>
{translate("agentHost.agentGui.turnSummaryShowFewerFiles")}
</span>
<ChevronRight
size={16}
strokeWidth={2.2}
aria-hidden="true"
className="-rotate-90"
/>
</button>
) : null}
</div>
</div>
</section>
);
}
function patchFailureMessage(
errorCode: string | undefined,
fallback: string
): string {
switch (errorCode) {
case "invalid-patch":
return translate("agentHost.agentGui.turnSummaryInvalidPatch");
case "patch-does-not-apply":
return translate("agentHost.agentGui.turnSummaryPatchDoesNotApply");
case "not-git-repo":
return translate("agentHost.agentGui.turnSummaryGitRequired");
default:
return fallback;
}
}
function TurnSummaryFileCard({
file,
workspaceRoot,
basePath,
expanded,
onLinkAction,
onToggle
}: {
file: AgentTurnSummaryFileVM;
workspaceRoot?: string | null;
basePath?: string | null;
expanded: boolean;
onLinkAction?: (action: WorkspaceLinkAction) => void;
onToggle: () => void;
}): JSX.Element {
"use memo";
const action = resolveWorkspaceFileLinkAction({
path: file.path,
workspaceRoot: workspaceRoot,
basePath,
source: "agent-file-change"
});
const canOpen = Boolean(action && onLinkAction);
const stats = summarizeFileDiff(file);
const preview = filePreview(file);
return (
<div className="agent-turn-summary-card__file">
<div className="agent-turn-summary-card__file-row flex min-w-0 items-center gap-2.5 overflow-hidden px-4 py-2.5">
<div className="flex min-w-0 flex-1 items-center gap-2.5">
<button
type="button"
className="group/file-toggle flex min-w-0 flex-1 items-center gap-1 overflow-hidden text-left"
aria-expanded={expanded}
onClick={onToggle}
>
<div className="min-w-0 flex-1 overflow-hidden">
<span
className={`agent-turn-summary-card__path flex min-w-0 max-w-full overflow-hidden whitespace-nowrap text-[13px] font-medium leading-5 text-[var(--text-secondary)] ${
file.changeType === "deleted" ? "line-through" : ""
}`}
title={file.path}
>
{file.directory ? (
<span className="agent-turn-summary-card__path-directory">
{file.directory}/
</span>
) : null}
<span className="agent-turn-summary-card__path-file">
{file.fileName}
</span>
</span>
</div>
<span className="shrink-0 text-[var(--text-tertiary)] opacity-0 transition-opacity group-hover/file-toggle:opacity-100 group-focus-visible/file-toggle:opacity-100">
<ChevronRight
size={14}
strokeWidth={2.2}
aria-hidden="true"
className={`transition-transform duration-150 ease-out ${expanded ? "rotate-90" : "rotate-0"}`}
/>
</span>
</button>
<span className="ml-auto inline-flex shrink-0 items-center gap-2 text-[11px] font-semibold">
{stats.added > 0 ? (
<span className="workspace-agents-status-panel__detail-tool-diff-added">
+{stats.added}
</span>
) : null}
{stats.removed > 0 ? (
<span className="workspace-agents-status-panel__detail-tool-diff-removed">
-{stats.removed}
</span>
) : null}
</span>
</div>
{canOpen ? (
<CanvasNodeGhostIconButton
aria-label={translate(
"agentHost.workspaceAgentSessionDetailOpenFile",
{
path: file.path
}
)}
onClick={() => {
onLinkAction?.(action as WorkspaceLinkAction);
}}
>
<DirectLinedIcon
width={14}
height={14}
aria-hidden="true"
className="text-[var(--text-secondary)]"
/>
</CanvasNodeGhostIconButton>
) : null}
</div>
{preview ? (
<CollapsibleReveal expanded={expanded} preMountOnIdle>
<div className="agent-turn-summary-card__preview rounded-none px-4 pb-3 pt-2">
{preview}
</div>
</CollapsibleReveal>
) : null}
</div>
);
}
function filePreview(file: AgentTurnSummaryFileVM): JSX.Element | null {
if (
file.changeType === "created" &&
file.content !== null &&
file.content !== undefined
) {
return (
<AgentCodeBlock
path={file.path}
content={file.content}
showHeader={false}
collapsible
flat
/>
);
}
if (file.unifiedDiff && isAgentUnifiedDiff(file.unifiedDiff)) {
return (
<AgentUnifiedPatchViewer
path={file.path}
diffText={file.unifiedDiff}
showHeader={false}
flat
/>
);
}
if (
file.changeType === "created" &&
file.newString !== null &&
file.newString !== undefined
) {
return (
<AgentCodeBlock
path={file.path}
content={file.newString}
showHeader={false}
collapsible
flat
/>
);
}
if (
(file.oldString !== null && file.oldString !== undefined) ||
(file.newString !== null && file.newString !== undefined)
) {
return (
<AgentMonacoDiffViewer
path={file.path}
oldValue={file.oldString ?? ""}
newValue={file.newString ?? ""}
flat
showHeader={false}
/>
);
}
if (
file.changeType === "deleted" &&
((file.content !== null && file.content !== undefined) ||
(file.unifiedDiff !== null && file.unifiedDiff !== undefined))
) {
return (
<AgentMonacoDiffViewer
path={file.path}
oldValue={file.oldString ?? file.content ?? file.unifiedDiff ?? ""}
newValue=""
flat
showHeader={false}
/>
);
}
if (file.content !== null && file.content !== undefined) {
return (
<AgentCodeBlock
path={file.path}
content={file.content}
showHeader={false}
collapsible
flat
/>
);
}
if (
file.unifiedDiff !== null &&
file.unifiedDiff !== undefined &&
file.changeType !== "deleted"
) {
return (
<AgentCodeBlock
path={file.path}
content={file.content ?? file.newString ?? file.unifiedDiff}
showHeader={false}
collapsible
flat
/>
);
}
return null;
}
function summarizeRowDiff(files: AgentTurnSummaryFileVM[]): {
added: number;
removed: number;
} {
return files.reduce(
(totals, file) => {
const stats = summarizeFileDiff(file);
return {
added: totals.added + stats.added,
removed: totals.removed + stats.removed
};
},
{ added: 0, removed: 0 }
);
}
function showPatchFailureToast(
agentHostApi: ReturnType<typeof useOptionalAgentHostApi>,
message: string
): void {
if (agentHostApi?.toast?.error) {
agentHostApi.toast.error(message);
return;
}
toast.error(message);
}
function buildExecutablePatchBatches(
sourceBatches: readonly AgentTurnSummaryPatchBatchVM[],
workspaceRoot?: string | null
): { cwd: string; diff: string }[] {
const batches: { cwd: string; diff: string }[] = [];
for (const batch of sourceBatches) {
if (
batch.changes.length === 0 ||
batch.changes.some(
(change) =>
!buildAgentTurnSummaryPatchDiff({
...batch,
changes: [change]
}).trim()
)
) {
return [];
}
const sourceCwd = patchBatchDirectoryCwd(
batch.cwd ?? workspaceRoot ?? null,
batch.changes,
workspaceRoot
);
const executionCwd = resolvePatchExecutionCwd(sourceCwd, workspaceRoot);
const diffCwd = resolvePatchDiffCwd({
executionCwd,
sourceCwd,
changes: batch.changes
});
const diff = buildAgentTurnSummaryPatchDiff({
...batch,
cwd: diffCwd
});
if (!executionCwd || !diff.trim()) {
return [];
}
batches.push({ cwd: executionCwd, diff });
}
return batches;
}
function hasCompletePatchCoverage(
files: readonly AgentTurnSummaryFileVM[],
batches: readonly AgentTurnSummaryPatchBatchVM[]
): boolean {
if (files.length === 0 || batches.length === 0) {
return false;
}
const visiblePaths = new Set(files.map((file) => file.path));
const patchPaths = batches.flatMap((batch) =>
batch.changes.map((change) => change.path)
);
return (
patchPaths.every((path) => visiblePaths.has(path)) &&
files.every((file) => patchPaths.includes(file.path))
);
}
function fallbackPatchFileCwd(
path: string,
workspaceRoot?: string | null
): string | null {
const root = workspaceRoot?.trim() ?? "";
if (root && root !== "/") {
return root;
}
const normalizedPath = path.trim().replaceAll("\\", "/").replace(/\/+$/, "");
if (!normalizedPath.startsWith("/")) {
return root || null;
}
const index = normalizedPath.lastIndexOf("/");
if (index <= 0) {
return "/";
}
return normalizedPath.slice(0, index);
}
function summarizeFileDiff(file: AgentTurnSummaryFileVM): {
added: number;
removed: number;
} {
return agentFileChangeStats({
changeType: file.changeType,
unifiedDiff: file.unifiedDiff,
content: file.content,
oldString: file.oldString,
newString: file.newString
});
}
@@ -0,0 +1,496 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { AgentActivityTurn } from "@tutti-os/agent-activity-core";
import type { JSX } from "react";
import { describe, expect, it, vi } from "vitest";
import type { AgentTranscriptRowVM } from "../contracts/agentTranscriptRowVM";
import type { AgentTranscriptTurnGroup } from "./agentTranscriptModel";
import { AgentTurnWorkSection as AgentTurnWorkSectionView } from "./AgentTurnWorkSection";
import type { AgentTurnDisclosureStore } from "./AgentTurnDisclosureContext";
import { buildAgentTurnWorkSectionModel } from "./agentTurnWorkSectionModel";
vi.mock("../../../i18n/index", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../i18n/index")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) =>
key === "agentHost.agentGui.turnProcessedSeconds"
? `Processed for ${String(options?.seconds)}s`
: key === "agentHost.agentGui.turnPeerDeviceOfflinePendingSync"
? "Other device offline · Progress pending sync"
: key === "agentHost.agentGui.turnPeerDeviceProgressSynchronizing"
? "Synchronizing progress from other device…"
: key
})
};
});
const disclosureStore: AgentTurnDisclosureStore = {
expandedOverrides: {},
setExpandedOverride: () => {}
};
describe("AgentTurnWorkSection", () => {
it("ticks only the duration label without rerendering transcript rows", () => {
vi.useFakeTimers();
vi.setSystemTime(50_000);
const renderRow = vi.fn((row: AgentTranscriptRowVM, rowIndex: number) => (
<div key={`${row.id}:${rowIndex}`}>{row.id}</div>
));
try {
render(
<AgentTurnWorkSection
group={turnGroup()}
sessionId="session-1"
turn={canonicalTurn()}
isActiveTurn
disclosureStore={disclosureStore}
renderRow={renderRow}
/>
);
expect(screen.getByText("Processed for 45s")).toBeTruthy();
expect(vi.getTimerCount()).toBe(1);
renderRow.mockClear();
act(() => vi.advanceTimersByTime(5_000));
expect(screen.getByText("Processed for 50s")).toBeTruthy();
expect(renderRow).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it.each([
["peer-offline" as const, "Other device offline · Progress pending sync"],
["synchronizing" as const, "Synchronizing progress from other device…"]
])("shows %s observation-gap copy instead of a live timer", (state, copy) => {
vi.useFakeTimers();
vi.setSystemTime(50_000);
try {
render(
<AgentTurnWorkSection
group={turnGroup()}
sessionId="session-1"
turn={canonicalTurn()}
isActiveTurn
observationGapPresentationState={state}
disclosureStore={disclosureStore}
renderRow={(row, rowIndex) => (
<div key={`${row.id}:${rowIndex}`}>{row.id}</div>
)}
/>
);
expect(screen.getByText(copy)).toBeTruthy();
expect(screen.queryByText("Processed for 45s")).toBeNull();
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
});
it("shows and ticks wall-clock time while the active turn is waiting", () => {
vi.useFakeTimers();
vi.setSystemTime(50_000);
try {
render(
<AgentTurnWorkSection
group={turnGroup()}
sessionId="session-1"
turn={canonicalTurn({ phase: "waiting" })}
isActiveTurn
disclosureStore={disclosureStore}
renderRow={(row, rowIndex) => (
<div key={`${row.id}:${rowIndex}`}>{row.id}</div>
)}
/>
);
expect(screen.getByText("Processed for 45s")).toBeTruthy();
expect(vi.getTimerCount()).toBe(1);
act(() => vi.advanceTimersByTime(5_000));
expect(screen.getByText("Processed for 50s")).toBeTruthy();
} finally {
vi.useRealTimers();
}
});
it("keeps the live timer when a running turn transitions to waiting", () => {
vi.useFakeTimers();
vi.setSystemTime(50_000);
const renderRow = (row: AgentTranscriptRowVM, rowIndex: number) => (
<div key={`${row.id}:${rowIndex}`}>{row.id}</div>
);
try {
const { rerender } = render(
<AgentTurnWorkSection
group={turnGroup()}
sessionId="session-1"
turn={canonicalTurn({ phase: "running" })}
isActiveTurn
disclosureStore={disclosureStore}
renderRow={renderRow}
/>
);
expect(screen.getByText("Processed for 45s")).toBeTruthy();
expect(vi.getTimerCount()).toBe(1);
rerender(
<AgentTurnWorkSection
group={turnGroup()}
sessionId="session-1"
turn={canonicalTurn({ phase: "waiting" })}
isActiveTurn
disclosureStore={disclosureStore}
renderRow={renderRow}
/>
);
expect(screen.getByText("Processed for 45s")).toBeTruthy();
expect(vi.getTimerCount()).toBe(1);
act(() => vi.advanceTimersByTime(5_000));
expect(screen.getByText("Processed for 50s")).toBeTruthy();
} finally {
vi.useRealTimers();
}
});
it("clears the live timer and freezes timing when the turn settles", () => {
vi.useFakeTimers();
vi.setSystemTime(50_000);
const renderRow = (row: AgentTranscriptRowVM, rowIndex: number) => (
<div key={`${row.id}:${rowIndex}`}>{row.id}</div>
);
try {
const { rerender } = render(
<AgentTurnWorkSection
group={turnGroup()}
sessionId="session-1"
turn={canonicalTurn()}
isActiveTurn
disclosureStore={disclosureStore}
renderRow={renderRow}
/>
);
expect(vi.getTimerCount()).toBe(1);
rerender(
<AgentTurnWorkSection
group={turnGroup()}
sessionId="session-1"
turn={canonicalTurn({
phase: "settled",
outcome: "completed",
settledAtUnixMs: 12_000
})}
isActiveTurn={false}
disclosureStore={disclosureStore}
renderRow={renderRow}
/>
);
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
});
it("renders interleaved turn rows in their original chronology", () => {
const expandedStore: AgentTurnDisclosureStore = {
expandedOverrides: { "session-1:turn-1": true },
setExpandedOverride: () => {}
};
const { container } = render(
<AgentTurnWorkSection
group={interleavedTurnGroup()}
sessionId="session-1"
turn={canonicalTurn({
phase: "settled",
outcome: "completed",
settledAtUnixMs: 15_000
})}
isActiveTurn={false}
disclosureStore={expandedStore}
renderRow={(row) => (
<div key={row.id} data-test-row-id={row.id}>
{row.kind === "message" ? row.messages[0]?.body : row.id}
</div>
)}
/>
);
expect(
[...container.querySelectorAll("[data-test-row-id]")].map(
(element) => element.textContent
)
).toEqual([
"First request",
"Earlier answer",
"Follow-up",
"tools",
"Final answer"
]);
});
it("collapses ordinary assistant replies while keeping follow-up guidance visible", () => {
render(
<AgentTurnWorkSection
group={interleavedTurnGroup()}
sessionId="session-1"
turn={canonicalTurn({
phase: "settled",
outcome: "completed",
settledAtUnixMs: 15_000
})}
isActiveTurn={false}
disclosureStore={disclosureStore}
renderRow={(row) => (
<div key={row.id} data-test-row-id={row.id}>
{row.kind === "message" ? row.messages[0]?.body : row.id}
</div>
)}
/>
);
expect(screen.queryByText("Earlier answer")).toBeNull();
expect(screen.getByText("Follow-up")).toBeTruthy();
expect(screen.getByText("Final answer")).toBeTruthy();
expect(screen.queryByText("tools")).toBeNull();
});
it("keeps the response-tail file diff panel visible when turn work is collapsed", () => {
const group = interleavedTurnGroup();
const summary: AgentTranscriptRowVM = {
kind: "turn-summary",
id: "file-diff",
turnId: "turn-1",
files: [],
fileCount: 0,
modifiedCount: 0,
createdCount: 0,
occurredAtUnixMs: 15_000
};
group.rows.push({ row: summary, rowIndex: group.rows.length });
const { container } = render(
<AgentTurnWorkSection
group={group}
sessionId="session-1"
turn={canonicalTurn({
phase: "settled",
outcome: "completed",
settledAtUnixMs: 15_000
})}
isActiveTurn={false}
disclosureStore={disclosureStore}
renderRow={(row) => (
<div key={row.id} data-test-row-id={row.id}>
{row.kind === "message" ? row.messages[0]?.body : row.id}
</div>
)}
/>
);
expect(screen.queryByText("tools")).toBeNull();
expect(screen.getByText("Final answer")).toBeTruthy();
expect(screen.getByText("file-diff")).toBeTruthy();
expect(
[...container.querySelectorAll("[data-test-row-id]")].map(
(element) => element.textContent
)
).toEqual(["First request", "Follow-up", "Final answer", "file-diff"]);
});
it("makes the duration text part of the turn disclosure button", () => {
const setExpandedOverride = vi.fn();
render(
<AgentTurnWorkSection
group={interleavedTurnGroup()}
sessionId="session-1"
turn={canonicalTurn({
phase: "settled",
outcome: "completed",
settledAtUnixMs: 15_000
})}
isActiveTurn={false}
disclosureStore={{
expandedOverrides: {},
setExpandedOverride
}}
renderRow={(row) => <div key={row.id}>{row.id}</div>}
/>
);
const duration = screen.getByText("agentHost.agentGui.turnTotalSeconds");
const toggle = screen.getByRole("button", {
name: "agentHost.agentGui.expandTurnWork"
});
expect(toggle).toContainElement(duration);
fireEvent.click(duration);
expect(setExpandedOverride).toHaveBeenCalledWith("session-1:turn-1", true);
});
it("keeps dynamic section spacing inside the animated height", () => {
const expandedStore: AgentTurnDisclosureStore = {
expandedOverrides: { "session-1:turn-1": true },
setExpandedOverride: () => {}
};
const { container } = render(
<AgentTurnWorkSection
group={interleavedTurnGroup()}
sessionId="session-1"
turn={canonicalTurn({
phase: "settled",
outcome: "completed",
settledAtUnixMs: 15_000
})}
isActiveTurn={false}
disclosureStore={expandedStore}
renderRow={(row) => <div key={row.id}>{row.id}</div>}
/>
);
expect(
container.querySelector("[data-agent-turn-work-section]")
).toHaveClass("grid");
expect(
container.querySelector("[data-agent-turn-work-section]")
).not.toHaveClass("gap-4");
expect(
container.querySelector(".agent-collapsible-reveal__inner")
).toHaveClass("grid", "gap-4", "pt-4");
});
});
function AgentTurnWorkSection({
group,
sessionId,
turn,
isActiveTurn,
observationGapPresentationState,
disclosureStore,
renderRow
}: {
group: AgentTranscriptTurnGroup;
sessionId: string;
turn: AgentActivityTurn;
isActiveTurn: boolean;
observationGapPresentationState?: "peer-offline" | "synchronizing";
disclosureStore: AgentTurnDisclosureStore;
renderRow: (
row: AgentTranscriptRowVM,
rowIndex: number,
renderKey?: string
) => JSX.Element;
}): JSX.Element {
const model = buildAgentTurnWorkSectionModel(group, turn, isActiveTurn, {
collapseIntermediateAssistantReplies: true,
liveObservationGapPresentationState: observationGapPresentationState
});
if (!model) {
throw new Error("Test expected a timing-enabled turn disclosure model");
}
return (
<AgentTurnWorkSectionView
model={model}
sessionId={sessionId}
turnKey={group.turnId ?? group.key}
disclosureStore={disclosureStore}
renderRow={renderRow}
/>
);
}
function canonicalTurn(
overrides: Partial<AgentActivityTurn> = {}
): AgentActivityTurn {
return {
agentSessionId: "session-1",
origin: "user_prompt",
phase: "running",
startedAtUnixMs: 5_000,
turnId: "turn-1",
updatedAtUnixMs: 6_000,
...overrides
};
}
function turnGroup(): AgentTranscriptTurnGroup {
const row: AgentTranscriptRowVM = {
kind: "message",
id: "user-row",
turnId: "turn-1",
speaker: "user",
messages: [
{
kind: "message-content",
id: "user-message",
turnId: "turn-1",
body: "Please fix it",
presentationKind: "content",
occurredAtUnixMs: 5_000
}
],
thinking: [],
occurredAtUnixMs: 5_000
};
return {
key: "turn-1",
turnId: "turn-1",
rows: [{ row, rowIndex: 0 }]
};
}
function interleavedTurnGroup(): AgentTranscriptTurnGroup {
const messageRow = (
id: string,
speaker: "user" | "assistant",
body: string,
isTurnFinalText = false
): AgentTranscriptRowVM => ({
kind: "message",
id,
turnId: "turn-1",
speaker,
messages: [
{
kind: "message-content",
id: `${id}:message`,
turnId: "turn-1",
body,
presentationKind: "content",
copyText: isTurnFinalText ? body : null,
...(isTurnFinalText ? { isTurnFinalText: true as const } : {}),
occurredAtUnixMs: 5_000
}
],
thinking: [],
occurredAtUnixMs: 5_000
});
const rows: AgentTranscriptRowVM[] = [
messageRow("user-1", "user", "First request"),
messageRow("assistant-1", "assistant", "Earlier answer"),
messageRow("user-2", "user", "Follow-up"),
{
kind: "tool-group",
id: "tools",
turnId: "turn-1",
grouped: true,
calls: [],
entries: [],
occurredAtUnixMs: 8_000
},
messageRow("assistant-2", "assistant", "Final answer", true)
];
return {
key: "turn-1",
turnId: "turn-1",
rows: rows.map((row, rowIndex) => ({ row, rowIndex }))
};
}
@@ -0,0 +1,267 @@
import { useCallback, useRef, type JSX, type ReactNode } from "react";
import { Button } from "@tutti-os/ui-system/components";
import { ChevronDownIcon } from "@tutti-os/ui-system/icons";
import { useTranslation } from "../../../i18n/index";
import { CollapsibleReveal } from "./CollapsibleReveal";
import type { AgentTurnDisclosureStore } from "./AgentTurnDisclosureContext";
import { useElapsedSeconds } from "./useElapsedSeconds";
import {
formatAgentTurnDuration,
type AgentTurnDuration,
type AgentTurnTiming,
type AgentTurnWorkSectionModel,
type AgentTurnWorkSectionRow
} from "./agentTurnWorkSectionModel";
interface AgentTurnWorkSectionProps {
model: AgentTurnWorkSectionModel;
sessionId: string;
turnKey: string;
showDivider?: boolean;
disclosureStore: AgentTurnDisclosureStore;
onDisclosureMotionChange?: (
turnKey: string,
active: boolean,
anchorElement?: HTMLElement | null
) => void;
footer?: ReactNode;
renderRow: (
row: AgentTurnWorkSectionRow["row"],
rowIndex: number,
renderKey?: string
) => JSX.Element;
}
export function AgentTurnWorkSection({
model,
sessionId,
turnKey,
showDivider = false,
disclosureStore,
onDisclosureMotionChange,
renderRow,
footer
}: AgentTurnWorkSectionProps): JSX.Element {
const { t } = useTranslation();
const disclosureKey = `${sessionId}:${turnKey}`;
const expanded = model.collapseEligible
? (disclosureStore.expandedOverrides[disclosureKey] ?? false)
: true;
const toggleLabel = expanded
? t("agentHost.agentGui.collapseTurnWork")
: t("agentHost.agentGui.expandTurnWork");
const pendingRevealTransitionsRef = useRef(0);
const disclosureMotionActiveRef = useRef(false);
const collapsibleSectionCount = model.sections.reduce(
(count, section) =>
count + (section.kind === "work" && model.collapseEligible ? 1 : 0),
0
);
const finishDisclosureMotion = useCallback(() => {
if (!disclosureMotionActiveRef.current) {
return;
}
disclosureMotionActiveRef.current = false;
pendingRevealTransitionsRef.current = 0;
onDisclosureMotionChange?.(turnKey, false);
}, [onDisclosureMotionChange, turnKey]);
const startDisclosureMotion = useCallback(
(anchorElement: HTMLElement | null) => {
pendingRevealTransitionsRef.current = collapsibleSectionCount;
if (collapsibleSectionCount === 0) {
return;
}
disclosureMotionActiveRef.current = true;
onDisclosureMotionChange?.(turnKey, true, anchorElement);
},
[collapsibleSectionCount, onDisclosureMotionChange, turnKey]
);
const handleRevealTransitionEnd = useCallback(() => {
if (!disclosureMotionActiveRef.current) {
return;
}
pendingRevealTransitionsRef.current -= 1;
if (pendingRevealTransitionsRef.current <= 0) {
finishDisclosureMotion();
}
}, [finishDisclosureMotion]);
return (
<div className="grid min-w-0" data-agent-turn-work-section={turnKey}>
{showDivider ? (
<div
className="mb-4 h-px w-full flex-none bg-[var(--line-2,var(--tutti-line-2))]"
data-testid="agent-transcript-turn-divider"
aria-hidden="true"
/>
) : null}
{model.leadingRows.length > 0 ? (
<div className="mb-4 grid gap-4">
{renderRows(model.leadingRows, renderRow)}
</div>
) : null}
<div
className="flex min-h-6 items-center gap-0.5 text-[12px] text-[var(--text-tertiary)]"
data-agent-turn-work-header={turnKey}
>
{model.collapseEligible ? (
<Button
type="button"
variant="chrome"
size="xs"
className="-ml-2 gap-0.5 px-2 text-[12px] font-normal"
aria-label={toggleLabel}
aria-expanded={expanded}
title={toggleLabel}
onClick={(event) => {
startDisclosureMotion(
event.currentTarget.closest<HTMLElement>(
"[data-agent-turn-work-header]"
)
);
disclosureStore.setExpandedOverride(disclosureKey, !expanded);
}}
>
<AgentTurnDurationLabel timing={model.timing} />
<ChevronDownIcon
aria-hidden="true"
data-icon="inline-end"
className={`transition-transform duration-150 ${
expanded ? "rotate-0" : "-rotate-90"
}`}
/>
</Button>
) : (
<AgentTurnDurationLabel timing={model.timing} />
)}
</div>
{model.sections.map((section, sectionIndex) => {
const content = renderRows(section.rows, renderRow);
if (section.kind !== "work" || !model.collapseEligible) {
const firstRow = section.rows[0];
return (
<div
key={`${section.kind}:${firstRow?.renderKey ?? firstRow?.row.id ?? sectionIndex}`}
className="grid gap-4 pt-4"
>
{content}
</div>
);
}
const firstRow = section.rows[0];
return (
<CollapsibleReveal
key={`work:${firstRow?.renderKey ?? firstRow?.row.id ?? sectionIndex}`}
expanded={expanded}
innerClassName="grid gap-4 pt-4"
onHeightTransitionEnd={handleRevealTransitionEnd}
>
{content}
</CollapsibleReveal>
);
})}
{footer}
</div>
);
}
function renderRows(
rows: readonly AgentTurnWorkSectionRow[],
renderRow: AgentTurnWorkSectionProps["renderRow"]
): ReactNode {
return rows.map(({ row, rowIndex, renderKey }) =>
renderRow(row, rowIndex, renderKey)
);
}
function AgentTurnDurationLabel({
timing
}: {
timing: AgentTurnTiming;
}): JSX.Element {
const { t } = useTranslation();
const liveElapsedSeconds = useElapsedSeconds(
timing.kind === "live" &&
timing.observationGapPresentationState === undefined &&
!Number.isFinite(timing.frozenAtUnixMs)
? timing.startedAtUnixMs
: null
);
if (
timing.kind === "live" &&
timing.observationGapPresentationState !== undefined
) {
return (
<span>
{timing.observationGapPresentationState === "peer-offline"
? t("agentHost.agentGui.turnPeerDeviceOfflinePendingSync")
: t("agentHost.agentGui.turnPeerDeviceProgressSynchronizing")}
</span>
);
}
const elapsedSeconds =
timing.kind === "live"
? Number.isFinite(timing.frozenAtUnixMs)
? Math.max(
0,
Math.floor(
((timing.frozenAtUnixMs as number) - timing.startedAtUnixMs) /
1_000
)
)
: (liveElapsedSeconds ?? 0)
: timing.elapsedSeconds;
return <span>{translateDuration(t, timing.kind, elapsedSeconds)}</span>;
}
function translateDuration(
t: ReturnType<typeof useTranslation>["t"],
kind: AgentTurnTiming["kind"],
elapsedSeconds: number
): string {
const duration = formatAgentTurnDuration(elapsedSeconds);
return kind === "live"
? translateLiveDuration(t, duration)
: translateSettledDuration(t, duration);
}
function translateLiveDuration(
t: ReturnType<typeof useTranslation>["t"],
duration: AgentTurnDuration
): string {
if (duration.kind === "seconds") {
return t("agentHost.agentGui.turnProcessedSeconds", {
seconds: duration.seconds
});
}
if (duration.kind === "minutes") {
return t("agentHost.agentGui.turnProcessedMinutes", {
minutes: duration.minutes
});
}
return t("agentHost.agentGui.turnProcessedMinutesSeconds", {
minutes: duration.minutes,
seconds: duration.seconds
});
}
function translateSettledDuration(
t: ReturnType<typeof useTranslation>["t"],
duration: AgentTurnDuration
): string {
if (duration.kind === "seconds") {
return t("agentHost.agentGui.turnTotalSeconds", {
seconds: duration.seconds
});
}
if (duration.kind === "minutes") {
return t("agentHost.agentGui.turnTotalMinutes", {
minutes: duration.minutes
});
}
return t("agentHost.agentGui.turnTotalMinutesSeconds", {
minutes: duration.minutes,
seconds: duration.seconds
});
}
@@ -0,0 +1,117 @@
import { useState, type JSX } from "react";
import {
AlertCircle,
CheckCircle2,
ChevronDown,
ChevronUp,
ListChecks,
type LucideIcon
} from "lucide-react";
import { translate } from "../../../i18n/index";
import { CollapsibleReveal } from "./CollapsibleReveal";
import type { AgentTuttiModeCheckpointWakeVM } from "../contracts/agentMessageRowVM";
// Friendly, collapsed summary for a daemon checkpoint-wake prompt. The agent
// still receives the full prompt verbatim; here the user sees a compact card
// keyed off the checkpoint kind and Issue, with an expand affordance that
// reveals the complete injected text.
export function AgentTuttiModeCheckpointWakeCard({
checkpointWake,
fullText
}: {
checkpointWake: AgentTuttiModeCheckpointWakeVM;
fullText: string;
}): JSX.Element {
"use memo";
const [expanded, setExpanded] = useState(false);
const title = translate(checkpointWakeTitleKey(checkpointWake.kind));
const Icon = checkpointWakeIcon(checkpointWake.kind);
const issueLabel = checkpointWake.issueId
? translate("agentHost.agentGui.tuttiModeCheckpointWakeIssue", {
issue: checkpointWake.issueId
})
: null;
const hasBody = fullText.trim().length > 0;
const toggleLabel = translate(
expanded
? "agentHost.agentGui.tuttiModeCheckpointWakeCollapse"
: "agentHost.agentGui.tuttiModeCheckpointWakeExpand"
);
return (
<section
data-testid="agent-tutti-checkpoint-wake-card"
data-checkpoint-kind={checkpointWake.kind}
className="box-border w-full min-w-0 rounded-[8px] border border-[var(--line-2)] bg-[var(--background-panel)] p-3"
>
<div className="flex min-w-0 items-center gap-2">
<Icon
size={15}
strokeWidth={2}
aria-hidden="true"
className="shrink-0 text-[var(--text-secondary)]"
/>
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-[var(--text-primary)]">
{title}
</span>
{hasBody ? (
<button
type="button"
data-testid="agent-tutti-checkpoint-wake-toggle"
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-[6px] px-1.5 text-[12px] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--transparency-hover)] hover:text-[var(--text-secondary)]"
aria-expanded={expanded}
onClick={() => setExpanded((value) => !value)}
>
{toggleLabel}
{expanded ? (
<ChevronUp size={14} strokeWidth={2} aria-hidden="true" />
) : (
<ChevronDown size={14} strokeWidth={2} aria-hidden="true" />
)}
</button>
) : null}
</div>
{issueLabel ? (
<div className="mt-1 min-w-0 truncate font-[var(--tsh-font-mono)] text-[11px] text-[var(--text-tertiary)]">
{issueLabel}
</div>
) : null}
{hasBody ? (
<CollapsibleReveal expanded={expanded} preMountOnIdle>
<pre className="mt-2 max-h-[320px] overflow-auto whitespace-pre-wrap break-words rounded-[6px] bg-[var(--transparency-block)] px-3 py-2 text-[12px] leading-5 text-[var(--text-secondary)]">
{fullText}
</pre>
</CollapsibleReveal>
) : null}
</section>
);
}
function checkpointWakeTitleKey(kind: string): string {
switch (kind) {
case "task_settled":
return "agentHost.agentGui.tuttiModeCheckpointWakeTaskSettled";
case "task_failed":
return "agentHost.agentGui.tuttiModeCheckpointWakeTaskFailed";
case "task_canceled":
return "agentHost.agentGui.tuttiModeCheckpointWakeTaskCanceled";
case "all_tasks_terminal":
return "agentHost.agentGui.tuttiModeCheckpointWakeGoalReview";
case "initial_schedule":
return "agentHost.agentGui.tuttiModeCheckpointWakeInitialSchedule";
default:
return "agentHost.agentGui.tuttiModeCheckpointWakeDefault";
}
}
function checkpointWakeIcon(kind: string): LucideIcon {
switch (kind) {
case "task_failed":
case "task_canceled":
return AlertCircle;
case "task_settled":
case "all_tasks_terminal":
return CheckCircle2;
default:
return ListChecks;
}
}
@@ -0,0 +1,63 @@
import type { JSX } from "react";
import { CheckCircle2 } from "lucide-react";
import { translate } from "../../../i18n/index";
import type { WorkspaceLinkAction } from "../../../contexts/workspace/presentation/renderer/actions/workspaceLinkActions";
import {
AgentMessageMarkdown,
type AgentMessageMarkdownWorkspaceAppIcon
} from "../../AgentMessageMarkdown";
import type { AgentTuttiPlanIssueLinkVM } from "../contracts/agentMessageRowVM";
// Friendly, non-error rendering for the daemon's durable plan→Issue reverse
// link (messageId "plan-issue:<issueID>"): a compact "Issue created from this
// plan" row with the workspace-issue mention rendered through the standard
// markdown mention-chip pipeline, so clicking it opens the Issue exactly like
// any other issue mention.
export function AgentTuttiPlanIssueLinkCard({
planIssueLink,
workspaceRoot,
basePath,
onLinkAction,
workspaceAppIcons
}: {
planIssueLink: AgentTuttiPlanIssueLinkVM;
workspaceRoot: string | null;
basePath: string;
onLinkAction?: (action: WorkspaceLinkAction) => void;
workspaceAppIcons?: readonly AgentMessageMarkdownWorkspaceAppIcon[];
}): JSX.Element {
"use memo";
const title = translate("agentHost.agentGui.tuttiModePlanIssueLinkCreated");
return (
<section
data-testid="agent-tutti-plan-issue-link-card"
data-plan-issue-id={planIssueLink.issueId}
className="box-border w-full min-w-0 rounded-[8px] border border-[var(--line-2)] bg-[var(--background-panel)] p-3"
>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<CheckCircle2
size={15}
strokeWidth={2}
aria-hidden="true"
className="shrink-0 text-[var(--text-secondary)]"
/>
<span className="shrink-0 text-[13px] font-medium text-[var(--text-primary)]">
{title}
</span>
<span className="min-w-0">
<AgentMessageMarkdown
inline
content={planIssueLink.mentionMarkdown}
onLinkAction={onLinkAction}
workspaceLinkContext={{
workspaceRoot,
basePath,
source: "agent-markdown"
}}
workspaceAppIcons={workspaceAppIcons}
/>
</span>
</div>
</section>
);
}
@@ -0,0 +1,74 @@
import type { JSX, KeyboardEvent } from "react";
import { Button } from "@tutti-os/ui-system";
import styles from "../../../agent-gui/agentGuiNode/AgentGUIConversation.styles";
export interface AgentUserMessageEditRetryControl {
pending: boolean;
labels: {
edit: string;
cancel: string;
submit: string;
};
onSubmit: (input: {
editedText: string;
turnId: string;
}) => boolean | Promise<boolean>;
}
export function AgentUserMessageEditor({
value,
pending,
labels,
onChange,
onCancel,
onSubmit,
onKeyDown
}: {
value: string;
pending: boolean;
labels: AgentUserMessageEditRetryControl["labels"];
onChange: (value: string) => void;
onCancel: () => void;
onSubmit: () => Promise<void>;
onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
}): JSX.Element {
"use memo";
return (
<div
className={`workspace-agents-status-panel__detail-user-message ${styles.userMessageBubble} ${styles.userMessageEditor}`}
aria-busy={pending ? "true" : undefined}
>
<textarea
autoFocus
className={styles.userMessageEditorTextarea}
value={value}
disabled={pending}
aria-label={labels.edit}
onChange={(event) => onChange(event.currentTarget.value)}
onKeyDown={onKeyDown}
/>
<div className={styles.userMessageEditorActions}>
<Button
className="rounded-[var(--radius-xl)] border-[var(--line-2)] dark:border-[var(--line-2)]"
disabled={pending}
size="sm"
type="button"
variant="outline"
onClick={onCancel}
>
{labels.cancel}
</Button>
<Button
className="rounded-[var(--radius-xl)]"
disabled={pending || value.trim() === ""}
size="sm"
type="button"
variant="default"
onClick={() => void onSubmit()}
>
{labels.submit}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,210 @@
import type { JSX } from "react";
import { Button } from "../../../app/renderer/components/ui/button";
import { translate } from "../../../i18n/index";
import { workspaceAgentProviderLabel } from "../../workspaceAgentProviderLabel";
import { useOpenAgentEnvPanel } from "../../agentEnv";
import { AgentMessageDetailsDisclosure } from "./AgentMessageDetailsDisclosure";
import {
classifyRecoverableAgentMessage,
isSharedCallerOwnerRemediableError,
resolveAgentErrorPresentation
} from "../../agentEnv/agentErrorPresentation";
import { useAgentVisibleErrorPresentation } from "../../visibleError/AgentVisibleErrorPresentationContext";
import type { AgentMessageContentVM } from "../contracts/agentMessageRowVM";
// All error banners use the light-red danger surface. Yellow/warning surfaces
// are banned for notice boxes — see "Badges And Status" in
// docs/conventions/desktop-visual-language.md.
const ERROR_BANNER_CLASS_NAME =
"border-[var(--on-danger-hover)] bg-[var(--on-danger)] text-[var(--state-danger)]";
// Builds a synthetic visibleError from a plain message whose text and terminal
// status identify a recognizable environment failure.
export function recoverVisibleErrorFromMessage(
message: AgentMessageContentVM,
provider: string | null | undefined
): AgentMessageContentVM | null {
const code = classifyRecoverableAgentMessage({
body: message.body,
statusKind: message.statusKind
});
if (!code) {
return null;
}
return {
...message,
visibleError: {
code,
phase: null,
provider: provider ?? null,
detail: message.body,
retryable: null
}
};
}
export function AgentVisibleErrorMessage({
message,
onExternalLink
}: {
message: AgentMessageContentVM;
onAuthLogin?: (provider?: string | null) => void;
onExternalLink?: (href: string) => void;
}): JSX.Element {
"use memo";
const openAgentEnvPanel = useOpenAgentEnvPanel();
const { overrides: presentationOverrides, scope } =
useAgentVisibleErrorPresentation();
const error = message.visibleError;
// One card for every run-failure code. The presentation (keyed on the codes
// the daemon actually emits — see agentErrorPresentation) supplies a granular,
// provider-aware message and, when the failure is something the env wizard can
// detect or repair, a single deep-linking call-to-action. Transient/server-side
// failures resolve to no focus, so no (misleading) wizard button is shown.
const providerLabel = workspaceAgentProviderLabel(
error?.provider ?? "unknown"
);
const presentation = resolveAgentErrorPresentation(error?.code, scope);
const insufficientCreditsOverride =
presentationOverrides?.insufficient_credits;
const presentationOverride =
scope !== "shared_caller" &&
error?.code === "insufficient_credits" &&
insufficientCreditsOverride?.providers.includes(error.provider ?? "")
? insufficientCreditsOverride
: undefined;
const rawDetail = error?.detail ?? "";
const providerHeadline =
error?.origin === "provider" && rawDetail.trim() ? rawDetail : null;
const headline =
providerHeadline ||
presentationOverride?.message ||
(presentation?.messageKey
? translate(presentation.messageKey, { provider: providerLabel })
: visibleErrorTitle(message));
const focus = presentation?.focus ?? null;
const actionKey = presentation?.actionKey ?? null;
const externalAction = presentationOverride?.action ?? null;
const hint = visibleErrorHint(message, scope);
const showRawDetail =
error?.origin !== "provider" &&
error?.detailAvailable === true &&
rawDetail !== "";
// Account limits are status notices, not process crashes. Provider payloads
// stay in the canonical model and diagnostics; raw upstream text is only
// rendered through the explicit disclosure below.
const isPlanOrQuotaLimit =
error?.code === "quota_or_rate_limit" ||
error?.code === "insufficient_credits" ||
error?.code === "model_not_allowed" ||
error?.code === "subscription_required";
return (
<section
role={isPlanOrQuotaLimit ? "status" : "alert"}
className={`box-border w-full min-w-0 rounded-[8px] border p-3 text-[13px] leading-5 text-[var(--text-primary)] ${ERROR_BANNER_CLASS_NAME}`}
>
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0 flex-1">
<div className="font-medium text-[var(--text-primary)]">
{headline}
</div>
{scope === "shared_caller" && error?.code ? (
<code className="mt-1 block break-all font-mono text-[10px] leading-4 text-[var(--text-secondary)]">
{error.code}
</code>
) : null}
{hint ? (
<div className="mt-1 text-[11px] text-[var(--text-secondary)]">
{hint}
</div>
) : null}
{showRawDetail ? (
<AgentMessageDetailsDisclosure
detail={rawDetail}
className="mt-1"
label={translate("agentHost.agentGui.visibleErrorRawDetails")}
/>
) : null}
</div>
{(externalAction?.url && onExternalLink) ||
(actionKey && focus && openAgentEnvPanel) ? (
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0"
onClick={() => {
if (externalAction?.url) {
onExternalLink?.(externalAction.url);
return;
}
if (focus && openAgentEnvPanel) {
openAgentEnvPanel({
provider: error?.provider ?? null,
focus
});
}
}}
>
{externalAction?.label || (actionKey ? translate(actionKey) : null)}
</Button>
) : null}
</div>
</section>
);
}
function visibleErrorTitle(message: AgentMessageContentVM): string {
const error = message.visibleError;
const provider = workspaceAgentProviderLabel(error?.provider ?? "unknown");
switch (error?.code) {
case "auth_required":
return translate("agentHost.agentGui.visibleErrorAuthRequired", {
provider
});
case "request_timed_out":
return translate("agentHost.agentGui.visibleErrorRequestTimedOut", {
provider
});
case "runtime_unavailable":
return translate("agentHost.agentGui.visibleErrorRuntimeUnavailable", {
provider
});
case "quota_or_rate_limit":
return translate("agentHost.agentGui.visibleErrorQuotaOrRateLimit", {
provider
});
default:
if (error?.phase === "start") {
return translate("agentHost.agentGui.visibleErrorStartFailed", {
provider
});
}
return translate("agentHost.agentGui.visibleErrorRequestFailed", {
provider
});
}
}
function visibleErrorHint(
message: AgentMessageContentVM,
scope: "local_owner" | "shared_caller"
): string | null {
const error = message.visibleError;
if (
scope === "shared_caller" &&
isSharedCallerOwnerRemediableError(error?.code)
) {
return translate("agentHost.agentGui.visibleErrorSharedCallerHint");
}
if (error?.code !== "auth_required") {
return null;
}
return translate(
"agentHost.agentGui.visibleErrorAuthRequiredLocalAgentHint",
{
provider: workspaceAgentProviderLabel(error.provider ?? "unknown")
}
);
}
@@ -0,0 +1,289 @@
import "@testing-library/jest-dom/vitest";
import {
act,
fireEvent,
render,
screen,
waitFor
} from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CollapsibleReveal } from "./CollapsibleReveal";
describe("CollapsibleReveal", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("mounts collapsed first, then expands on the next frame for push animation", async () => {
let scheduledFrame: FrameRequestCallback | null = null;
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
configurable: true,
get: () => 42
});
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
scheduledFrame = callback;
return 1;
});
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(
() => undefined
);
const { rerender } = render(
<CollapsibleReveal expanded={false}>
<div>detail content</div>
</CollapsibleReveal>
);
expect(screen.queryByText("detail content")).toBeNull();
rerender(
<CollapsibleReveal expanded>
<div>detail content</div>
</CollapsibleReveal>
);
expect(screen.queryByText("detail content")).toBeNull();
act(() => {
scheduledFrame?.(8);
});
const reveal = screen
.getByText("detail content")
.closest(".agent-collapsible-reveal");
expect(reveal).toHaveAttribute("data-expanded", "false");
expect(reveal).toHaveStyle({ height: "0px" });
act(() => {
scheduledFrame?.(16);
});
expect(reveal).toHaveAttribute("data-expanded", "true");
expect(reveal).toHaveStyle({ height: "42px" });
fireEvent.transitionEnd(reveal as HTMLElement, { propertyName: "height" });
await waitFor(() => expect(reveal).toHaveStyle({ height: "auto" }));
rerender(
<CollapsibleReveal expanded={false}>
<div>detail content</div>
</CollapsibleReveal>
);
expect(reveal).toHaveAttribute("data-expanded", "false");
expect(reveal).toHaveStyle({ height: "42px" });
act(() => {
scheduledFrame?.(32);
});
expect(reveal).toHaveStyle({ height: "0px" });
fireEvent.transitionEnd(reveal as HTMLElement, { propertyName: "height" });
expect(screen.queryByText("detail content")).toBeNull();
});
it("pre-mounts collapsed content on idle without starting the reveal animation", () => {
let idleCallback: IdleRequestCallback | null = null;
vi.stubGlobal("requestIdleCallback", (callback: IdleRequestCallback) => {
idleCallback = callback;
return 1;
});
vi.stubGlobal("cancelIdleCallback", () => undefined);
vi.spyOn(window, "requestAnimationFrame");
render(
<CollapsibleReveal expanded={false} preMountOnIdle>
<div>detail content</div>
</CollapsibleReveal>
);
expect(screen.queryByText("detail content")).toBeNull();
act(() => {
idleCallback?.({
didTimeout: false,
timeRemaining: () => 50
});
});
const reveal = screen
.getByText("detail content")
.closest(".agent-collapsible-reveal");
expect(reveal).toHaveAttribute("data-expanded", "false");
expect(reveal).toHaveAttribute("aria-hidden", "true");
expect(reveal).toHaveStyle({ height: "0px" });
expect(window.requestAnimationFrame).not.toHaveBeenCalled();
});
it("collapses pre-mounted content without re-reading a stale layout height", async () => {
let scheduledFrame: FrameRequestCallback | null = null;
let scrollHeightReads = 0;
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
configurable: true,
get: () => {
scrollHeightReads += 1;
return 42;
}
});
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
scheduledFrame = callback;
return 1;
});
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(
() => undefined
);
const { rerender } = render(
<CollapsibleReveal expanded={false} preMountOnIdle>
<div>detail content</div>
</CollapsibleReveal>
);
rerender(
<CollapsibleReveal expanded preMountOnIdle>
<div>detail content</div>
</CollapsibleReveal>
);
act(() => {
scheduledFrame?.(8);
});
const reveal = screen
.getByText("detail content")
.closest(".agent-collapsible-reveal");
act(() => {
scheduledFrame?.(16);
});
expect(reveal).toHaveStyle({ height: "42px" });
const readsAfterExpansion = scrollHeightReads;
expect(readsAfterExpansion).toBeGreaterThan(0);
fireEvent.transitionEnd(reveal as HTMLElement, { propertyName: "height" });
await waitFor(() => expect(reveal).toHaveStyle({ height: "auto" }));
rerender(
<CollapsibleReveal expanded={false} preMountOnIdle>
<div>detail content</div>
</CollapsibleReveal>
);
expect(reveal).toHaveStyle({ height: "42px" });
expect(scrollHeightReads).toBe(readsAfterExpansion);
act(() => {
scheduledFrame?.(32);
});
expect(reveal).toHaveStyle({ height: "0px" });
fireEvent.transitionEnd(reveal as HTMLElement, { propertyName: "height" });
expect(screen.getByText("detail content")).toBeInTheDocument();
});
it("tracks resize updates at auto height without animating and collapses from the latest size", () => {
let resizeCallback: ResizeObserverCallback | null = null;
const observe = vi.fn();
const disconnect = vi.fn();
vi.stubGlobal(
"ResizeObserver",
vi.fn().mockImplementation(function ResizeObserverMock(
callback: ResizeObserverCallback
) {
resizeCallback = callback;
return { observe, disconnect };
})
);
vi.spyOn(window, "requestAnimationFrame");
const { rerender } = render(
<CollapsibleReveal expanded>
<div>streaming detail content</div>
</CollapsibleReveal>
);
const reveal = screen
.getByText("streaming detail content")
.closest(".agent-collapsible-reveal");
const inner = reveal?.querySelector(".agent-collapsible-reveal__inner");
Object.defineProperty(inner as Element, "scrollHeight", {
configurable: true,
value: 84
});
expect(reveal).toHaveStyle({ height: "auto" });
expect(observe).toHaveBeenCalled();
act(() => {
resizeCallback?.(
[
{
contentRect: { height: 84 }
} as ResizeObserverEntry
],
{} as ResizeObserver
);
});
expect(window.requestAnimationFrame).not.toHaveBeenCalled();
expect(reveal).toHaveStyle({ height: "auto" });
rerender(
<CollapsibleReveal expanded={false}>
<div>streaming detail content</div>
</CollapsibleReveal>
);
expect(reveal).toHaveStyle({ height: "84px" });
});
it("reports a collapsing height transition once when content unmounts", () => {
const onHeightTransitionEnd = vi.fn();
const { rerender } = render(
<CollapsibleReveal expanded onHeightTransitionEnd={onHeightTransitionEnd}>
<div>detail content</div>
</CollapsibleReveal>
);
const reveal = screen
.getByText("detail content")
.closest(".agent-collapsible-reveal");
rerender(
<CollapsibleReveal
expanded={false}
onHeightTransitionEnd={onHeightTransitionEnd}
>
<div>detail content</div>
</CollapsibleReveal>
);
fireEvent.transitionEnd(reveal as HTMLElement, { propertyName: "height" });
expect(onHeightTransitionEnd).toHaveBeenCalledTimes(1);
});
it("reports an expansion canceled before its mount frame", () => {
vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 1);
const onHeightTransitionEnd = vi.fn();
const { rerender } = render(
<CollapsibleReveal
expanded={false}
onHeightTransitionEnd={onHeightTransitionEnd}
>
<div>detail content</div>
</CollapsibleReveal>
);
rerender(
<CollapsibleReveal expanded onHeightTransitionEnd={onHeightTransitionEnd}>
<div>detail content</div>
</CollapsibleReveal>
);
rerender(
<CollapsibleReveal
expanded={false}
onHeightTransitionEnd={onHeightTransitionEnd}
>
<div>detail content</div>
</CollapsibleReveal>
);
expect(onHeightTransitionEnd).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,251 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type JSX,
type ReactNode,
type TransitionEvent
} from "react";
interface CollapsibleRevealProps {
expanded: boolean;
children: ReactNode;
className?: string;
innerClassName?: string;
onHeightTransitionEnd?: () => void;
preMountOnIdle?: boolean;
}
export function CollapsibleReveal({
expanded,
children,
className,
innerClassName,
onHeightTransitionEnd,
preMountOnIdle = false
}: CollapsibleRevealProps): JSX.Element | null {
"use memo";
const [mounted, setMounted] = useState(expanded);
const [visible, setVisible] = useState(expanded);
const [height, setHeight] = useState<string>(expanded ? "auto" : "0px");
const rootRef = useRef<HTMLDivElement | null>(null);
const innerRef = useRef<HTMLDivElement | null>(null);
const heightRef = useRef(height);
const measuredHeightRef = useRef<number | null>(null);
const previousExpandedRef = useRef(expanded);
const heightTransitionPendingRef = useRef(false);
const setRootRef = useCallback(
(node: HTMLDivElement | null) => {
if (
rootRef.current !== null &&
node === null &&
heightTransitionPendingRef.current
) {
heightTransitionPendingRef.current = false;
onHeightTransitionEnd?.();
}
rootRef.current = node;
},
[onHeightTransitionEnd]
);
const setRevealHeight = useCallback((nextHeight: string) => {
heightRef.current = nextHeight;
setHeight(nextHeight);
}, []);
useLayoutEffect(() => {
if (!expanded || mounted) {
return undefined;
}
if (preMountOnIdle) {
setMounted(true);
return undefined;
}
let mountFrameStarted = false;
const animationFrame = requestAnimationFrame(() => {
mountFrameStarted = true;
setMounted(true);
});
return () => {
cancelAnimationFrame(animationFrame);
if (!mountFrameStarted) {
onHeightTransitionEnd?.();
}
};
}, [expanded, mounted, onHeightTransitionEnd, preMountOnIdle]);
useEffect(() => {
if (!preMountOnIdle || mounted || expanded) {
return undefined;
}
const mountCollapsedContent = () => setMounted(true);
if ("requestIdleCallback" in window) {
const idleCallbackId = window.requestIdleCallback(mountCollapsedContent, {
timeout: 600
});
return () => window.cancelIdleCallback(idleCallbackId);
}
const timeoutId = globalThis.setTimeout(mountCollapsedContent, 120);
return () => globalThis.clearTimeout(timeoutId);
}, [expanded, mounted, preMountOnIdle]);
useLayoutEffect(() => {
if (!mounted) {
return undefined;
}
const root = rootRef.current;
if (!root) {
return undefined;
}
const wasExpanded = previousExpandedRef.current;
previousExpandedRef.current = expanded;
if (expanded) {
if (wasExpanded && visible && height === "auto") {
return undefined;
}
heightTransitionPendingRef.current = true;
setVisible(false);
setRevealHeight("0px");
const animationFrame = requestAnimationFrame(() => {
const measured = Math.max(
root.scrollHeight,
innerRef.current?.scrollHeight ?? 0
);
measuredHeightRef.current = measured;
// Make the reveal accessible on the first measure frame even when
// children have not reported a measurable height yet.
setVisible(true);
setRevealHeight(measured > 0 ? `${measured}px` : "auto");
});
return () => {
cancelAnimationFrame(animationFrame);
};
}
if (!wasExpanded) {
heightTransitionPendingRef.current = false;
setVisible(false);
setRevealHeight("0px");
return undefined;
}
const renderedHeight = root.getBoundingClientRect().height;
const cachedHeight = measuredHeightRef.current;
const measuredHeight =
renderedHeight > 0 ? renderedHeight : (cachedHeight ?? root.scrollHeight);
measuredHeightRef.current = measuredHeight;
heightTransitionPendingRef.current = true;
setRevealHeight(`${measuredHeight}px`);
setVisible(false);
const animationFrame = requestAnimationFrame(() => {
setRevealHeight("0px");
});
return () => cancelAnimationFrame(animationFrame);
}, [expanded, mounted]);
useLayoutEffect(() => {
if (!mounted || !expanded || !visible) {
return undefined;
}
const root = rootRef.current;
const inner = innerRef.current;
if (!root || !inner || typeof ResizeObserver === "undefined") {
return undefined;
}
let animationFrame: number | null = null;
const resizeObserver = new ResizeObserver((entries) => {
const innerScrollHeight = inner.scrollHeight;
const nextHeight = Math.ceil(
innerScrollHeight > 0
? innerScrollHeight
: (entries[0]?.contentRect.height ?? 0)
);
const previousHeight = measuredHeightRef.current;
if (!nextHeight || previousHeight === nextHeight) {
return;
}
measuredHeightRef.current = nextHeight;
if (heightRef.current === "auto") {
return;
}
setRevealHeight(
`${previousHeight ?? root.getBoundingClientRect().height}px`
);
if (animationFrame !== null) {
cancelAnimationFrame(animationFrame);
}
animationFrame = requestAnimationFrame(() => {
setRevealHeight(`${nextHeight}px`);
});
});
resizeObserver.observe(inner);
return () => {
if (animationFrame !== null) {
cancelAnimationFrame(animationFrame);
}
resizeObserver.disconnect();
};
}, [expanded, mounted, setRevealHeight, visible]);
if (!mounted) {
return null;
}
const handleTransitionEnd = (event: TransitionEvent<HTMLDivElement>) => {
if (
event.target !== event.currentTarget ||
(event.propertyName ? event.propertyName !== "height" : false)
) {
return;
}
if (visible) {
setRevealHeight("auto");
} else if (!expanded && !preMountOnIdle) {
setMounted(false);
}
if (heightTransitionPendingRef.current) {
heightTransitionPendingRef.current = false;
onHeightTransitionEnd?.();
}
};
const rootStyle: CSSProperties = { height };
return (
<div
ref={setRootRef}
className={["agent-collapsible-reveal", className ?? ""]
.filter(Boolean)
.join(" ")}
data-expanded={
preMountOnIdle && expanded ? "true" : visible ? "true" : "false"
}
aria-hidden={visible ? undefined : true}
style={rootStyle}
onTransitionEnd={handleTransitionEnd}
>
<div
ref={innerRef}
className={["agent-collapsible-reveal__inner", innerClassName ?? ""]
.filter(Boolean)
.join(" ")}
>
{children}
</div>
</div>
);
}
@@ -0,0 +1,73 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ConversationImageContextMenu } from "./ConversationImageContextMenu";
const { copyImageToClipboardMock } = vi.hoisted(() => ({
copyImageToClipboardMock: vi.fn()
}));
vi.mock("../lib/copyImageToClipboard", () => ({
copyImageToClipboard: copyImageToClipboardMock
}));
describe("ConversationImageContextMenu", () => {
afterEach(() => {
copyImageToClipboardMock.mockReset();
});
it("wraps children in a trigger element by default", () => {
render(
<ConversationImageContextMenu src="blob:preview">
<img data-testid="image" alt="" />
</ConversationImageContextMenu>
);
const trigger = document.querySelector(
'[data-slot="context-menu-trigger"]'
);
expect(trigger).not.toBeNull();
// The image is nested inside the wrapper trigger, not the trigger itself.
expect(screen.getByTestId("image").getAttribute("data-slot")).not.toBe(
"context-menu-trigger"
);
expect(trigger?.querySelector('[data-testid="image"]')).not.toBeNull();
});
it("attaches the trigger directly to the child when asChild is set", () => {
render(
<ConversationImageContextMenu src="blob:preview" asChild>
<img data-testid="image" alt="" />
</ConversationImageContextMenu>
);
// No extra wrapper: the image element itself becomes the trigger. This is
// what lets the zoomed image keep its library-managed positioning.
expect(screen.getByTestId("image").getAttribute("data-slot")).toBe(
"context-menu-trigger"
);
});
it("copies and closes the menu on pointer down", async () => {
copyImageToClipboardMock.mockResolvedValue(true);
render(
<ConversationImageContextMenu src="blob:preview">
<img data-testid="image" alt="" />
</ConversationImageContextMenu>
);
fireEvent.contextMenu(screen.getByTestId("image"));
const copyItem = await screen.findByText("Copy image");
fireEvent.pointerDown(copyItem, { button: 0 });
expect(copyImageToClipboardMock).toHaveBeenCalledWith(
"blob:preview",
expect.anything()
);
await waitFor(() => {
expect(screen.queryByText("Copy image")).toBeNull();
});
});
});
@@ -0,0 +1,72 @@
import {
useCallback,
useRef,
useState,
type CSSProperties,
type JSX,
type ReactNode
} from "react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger
} from "@tutti-os/ui-system";
import { useOptionalAgentHostApi } from "../../../agentActivityHost";
import { copyImageToClipboard } from "../lib/copyImageToClipboard";
import { translate } from "../../../i18n/index";
export function ConversationImageContextMenu({
src,
children,
asChild = false,
contentStyle
}: {
src: string;
children: ReactNode;
/**
* Attach the right-click listener directly to the child element instead of a
* wrapper span. Used for the zoomed image, whose positioning the zoom library
* manages and must not be disturbed by an extra wrapper element.
*/
asChild?: boolean;
/**
* Override the menu content style. Used by the zoomed image to raise the
* menu above the zoom modal (which sits above the default popover z-index).
*/
contentStyle?: CSSProperties;
}): JSX.Element {
const agentHostApi = useOptionalAgentHostApi();
const copyStartedRef = useRef(false);
const [menuResetKey, setMenuResetKey] = useState(0);
const copyAndClose = useCallback(() => {
if (copyStartedRef.current) {
return;
}
copyStartedRef.current = true;
setMenuResetKey((key) => key + 1);
void copyImageToClipboard(src, agentHostApi?.clipboard).finally(() => {
copyStartedRef.current = false;
});
}, [agentHostApi?.clipboard, src]);
return (
<ContextMenu key={menuResetKey}>
<ContextMenuTrigger asChild={asChild}>{children}</ContextMenuTrigger>
<ContextMenuContent style={contentStyle}>
<ContextMenuItem
onClick={copyAndClose}
onPointerDown={(event) => {
if (event.button !== 0) {
return;
}
event.preventDefault();
copyAndClose();
}}
onSelect={copyAndClose}
>
{translate("agentHost.agentGui.copyImage")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
@@ -0,0 +1,57 @@
import { useState, type JSX } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import type { WorkspaceAgentActivityTimelineItem } from "../../workspaceAgentTimelineTypes";
import { CollapsibleReveal } from "./CollapsibleReveal";
interface RawTimelineJsonDisclosureProps {
items: readonly WorkspaceAgentActivityTimelineItem[] | null | undefined;
label: string;
}
export function RawTimelineJsonDisclosure({
items,
label
}: RawTimelineJsonDisclosureProps): JSX.Element | null {
"use memo";
const [expanded, setExpanded] = useState(false);
const json = rawTimelineJson(items);
if (!json) {
return null;
}
return (
<div className="mt-2 rounded-md border border-[color:color-mix(in_srgb,var(--state-danger)_18%,transparent)] bg-[color:color-mix(in_srgb,var(--background-fronted)_92%,var(--state-danger))] px-2 py-1.5 text-[11px] text-[var(--state-danger)]">
<button
type="button"
className="inline-flex items-center gap-1.5 border-0 bg-transparent p-0 text-left font-semibold text-[var(--state-danger)]"
aria-expanded={expanded}
onClick={() => setExpanded((value) => !value)}
>
{expanded ? (
<ChevronDown size={13} strokeWidth={2.2} aria-hidden />
) : (
<ChevronRight size={13} strokeWidth={2.2} aria-hidden />
)}
<span>{label}</span>
</button>
<CollapsibleReveal expanded={expanded} preMountOnIdle>
<pre className="mt-2 max-h-[320px] overflow-auto whitespace-pre-wrap break-words rounded-md border border-[var(--line-2)] bg-[var(--background-fronted)] px-3 py-2 font-mono text-[11px] leading-5 text-[var(--text-primary)]">
<code>{json}</code>
</pre>
</CollapsibleReveal>
</div>
);
}
function rawTimelineJson(
items: readonly WorkspaceAgentActivityTimelineItem[] | null | undefined
): string | null {
if (!items || items.length === 0) {
return null;
}
try {
return JSON.stringify(items.length === 1 ? items[0] : items, null, 2);
} catch {
return null;
}
}
@@ -0,0 +1,6 @@
const agentSystemNoticeStyles = {
routine: "border-[var(--line-2)] bg-[var(--transparency-block)]",
contextHandoff: "border-[var(--on-danger-hover)] bg-[var(--on-danger)]"
} as const;
export default agentSystemNoticeStyles;
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import type { AgentTranscriptRowVM } from "../contracts/agentTranscriptRowVM";
import { assessAgentTranscriptComplexity } from "./agentTranscriptComplexity";
describe("assessAgentTranscriptComplexity", () => {
it("does not virtualize normal short transcripts below the old turn threshold", () => {
const assessment = assessAgentTranscriptComplexity(
Array.from({ length: 12 }, (_, index) => ({
rows: [{ row: messageRow(index, "Short update.") }]
}))
);
expect(assessment.shouldVirtualize).toBe(false);
});
it("keeps the existing high turn-count trigger", () => {
const assessment = assessAgentTranscriptComplexity(
Array.from({ length: 30 }, (_, index) => ({
rows: [{ row: messageRow(index, "Short update.") }]
}))
);
expect(assessment.shouldVirtualize).toBe(true);
expect(assessment.turnCount).toBe(30);
});
it("does not virtualize a single complex turn that cannot elide any turns", () => {
const assessment = assessAgentTranscriptComplexity([
{
rows: [
{
row: messageRow(
0,
[
"Complex turn intro.",
longText(9000),
"```ts",
"const result = runLargeExample();",
"```",
"```json",
'{"ok":true}',
"```",
"| File | Status |",
"| --- | --- |",
"| app.tsx | updated |",
"![Preview](preview.png)"
].join("\n")
)
}
]
}
]);
expect(assessment.shouldVirtualize).toBe(false);
expect(assessment.turnCount).toBe(1);
expect(assessment.maxTurnScore).toBeGreaterThanOrEqual(24);
});
it("virtualizes complex transcripts once enough turns can be elided", () => {
const assessment = assessAgentTranscriptComplexity(
Array.from({ length: 8 }, (_, index) => ({
rows: [{ row: messageRow(index, longText(9000)) }]
}))
);
expect(assessment.shouldVirtualize).toBe(true);
expect(assessment.turnCount).toBe(8);
});
it("virtualizes accumulated moderate complexity before thirty turns", () => {
const assessment = assessAgentTranscriptComplexity(
Array.from({ length: 20 }, (_, index) => ({
rows: [{ row: messageRow(index, longText(1200)) }]
}))
);
expect(assessment.shouldVirtualize).toBe(true);
expect(assessment.turnCount).toBe(20);
});
});
function messageRow(index: number, body: string): AgentTranscriptRowVM {
return {
kind: "message",
id: `row-${index}`,
turnId: `turn-${index}`,
speaker: "assistant",
messages: [
{
kind: "message-content",
id: `message-${index}`,
turnId: `turn-${index}`,
body,
presentationKind: "content",
occurredAtUnixMs: index
}
],
thinking: [],
occurredAtUnixMs: index
};
}
function longText(length: number): string {
return "x".repeat(length);
}
@@ -0,0 +1,193 @@
import type { AgentTranscriptRowVM } from "../contracts/agentTranscriptRowVM";
export interface AgentTranscriptComplexity {
totalScore: number;
maxTurnScore: number;
turnScores: readonly number[];
}
export interface AgentTranscriptComplexityAssessment extends AgentTranscriptComplexity {
shouldVirtualize: boolean;
turnCount: number;
}
const AGENT_TRANSCRIPT_COMPLEXITY_VIRTUALIZATION_SCORE = 40;
const AGENT_TRANSCRIPT_COMPLEXITY_SINGLE_TURN_SCORE = 24;
const AGENT_TRANSCRIPT_COMPLEXITY_TURN_COUNT = 30;
const AGENT_TRANSCRIPT_COMPLEXITY_MINIMUM_TURN_COUNT = 8;
export function assessAgentTranscriptComplexity(
turnGroups: ReadonlyArray<{
rows: ReadonlyArray<{ row: AgentTranscriptRowVM }>;
}>
): AgentTranscriptComplexityAssessment {
const complexity = calculateAgentTranscriptComplexity(turnGroups);
return {
...complexity,
shouldVirtualize: shouldVirtualizeAgentTranscript({
turnCount: turnGroups.length,
complexity
}),
turnCount: turnGroups.length
};
}
export function calculateAgentTranscriptComplexity(
turnGroups: ReadonlyArray<{
rows: ReadonlyArray<{ row: AgentTranscriptRowVM }>;
}>
): AgentTranscriptComplexity {
const turnScores = turnGroups.map((group) => calculateTurnScore(group.rows));
const totalScore = turnScores.reduce((total, score) => total + score, 0);
return {
totalScore,
maxTurnScore: Math.max(0, ...turnScores),
turnScores
};
}
export function shouldVirtualizeAgentTranscript(input: {
turnCount: number;
complexity: AgentTranscriptComplexity;
}): boolean {
if (input.turnCount < AGENT_TRANSCRIPT_COMPLEXITY_MINIMUM_TURN_COUNT) {
return false;
}
return (
input.turnCount >= AGENT_TRANSCRIPT_COMPLEXITY_TURN_COUNT ||
input.complexity.totalScore >=
AGENT_TRANSCRIPT_COMPLEXITY_VIRTUALIZATION_SCORE ||
input.complexity.maxTurnScore >=
AGENT_TRANSCRIPT_COMPLEXITY_SINGLE_TURN_SCORE
);
}
function calculateTurnScore(
rows: ReadonlyArray<{ row: AgentTranscriptRowVM }>
): number {
let rowCount = 0;
let charCount = 0;
let codeFenceCount = 0;
let tableCount = 0;
let toolCallCount = 0;
let thinkingBlockCount = 0;
let imageCount = 0;
for (const { row } of rows) {
rowCount += 1;
if (row.kind === "generated-image") {
imageCount += 1;
continue;
}
if (row.kind === "message") {
for (const message of row.messages) {
charCount += message.body.length;
codeFenceCount += countCodeFences(message.body);
tableCount += countMarkdownTables(message.body);
imageCount +=
(message.images?.length ?? 0) + countMarkdownImages(message.body);
}
for (const thinking of row.thinking) {
thinkingBlockCount += 1;
charCount += thinking.body.length;
codeFenceCount += countCodeFences(thinking.body);
}
continue;
}
if (row.kind === "tool-group") {
toolCallCount += row.calls.length;
thinkingBlockCount += row.entries.filter(
(entry) => entry.kind === "thinking"
).length;
charCount += (row.summary ?? "").length;
continue;
}
if (row.kind === "turn-summary") {
charCount += row.files.reduce(
(total, file) =>
total +
file.label.length +
file.path.length +
(file.unifiedDiff?.length ?? 0) +
(file.content?.length ?? 0),
0
);
}
}
return (
rowCount +
charCount / 1200 +
codeFenceCount * 4 +
tableCount * 5 +
toolCallCount * 2 +
thinkingBlockCount * 2 +
imageCount * 4
);
}
function countCodeFences(value: string): number {
let count = 0;
for (const line of value.split("\n")) {
const trimmed = line.trimStart();
if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
count += 1;
}
}
return Math.ceil(count / 2);
}
function countMarkdownTables(value: string): number {
const lines = value.split("\n");
let count = 0;
for (let index = 1; index < lines.length; index += 1) {
if (
looksLikeTableSeparator(lines[index] ?? "") &&
lines[index - 1]?.includes("|")
) {
count += 1;
}
}
return count;
}
function countMarkdownImages(value: string): number {
let count = 0;
let index = 0;
while (index < value.length) {
const imageStart = value.indexOf("![", index);
if (imageStart < 0) {
break;
}
const labelEnd = value.indexOf("]", imageStart + 2);
const targetStart = labelEnd >= 0 ? value.indexOf("(", labelEnd) : -1;
const targetEnd = targetStart >= 0 ? value.indexOf(")", targetStart) : -1;
if (
labelEnd >= 0 &&
targetStart === labelEnd + 1 &&
targetEnd > targetStart
) {
count += 1;
index = targetEnd + 1;
continue;
}
index = imageStart + 2;
}
return count;
}
function looksLikeTableSeparator(line: string): boolean {
const cells = line
.split("|")
.map((cell) => cell.trim())
.filter(Boolean);
return (
cells.length > 0 &&
cells.every((cell) => {
const normalized = cell.replace(/:/gu, "");
return (
normalized.length >= 3 && [...normalized].every((char) => char === "-")
);
})
);
}
@@ -0,0 +1,12 @@
export function stringListEquals(
left: readonly string[] | null | undefined,
right: readonly string[] | null | undefined
): boolean {
if (left === right) {
return true;
}
if (!left || !right || left.length !== right.length) {
return false;
}
return left.every((value, index) => value === right[index]);
}
@@ -0,0 +1,259 @@
import { describe, expect, it } from "vitest";
import type { AgentTranscriptPresentationKind } from "../contracts/agentTranscriptPresentation";
import type { AgentTranscriptRowVM } from "../contracts/agentTranscriptRowVM";
import {
attachLeadingToolRowsToFollowingMessages,
buildAgentParticipantTurnProjection,
buildAgentTranscriptTurnGroups,
findTurnDividerRowIndexes,
summarizeUserMessageRow,
transcriptRowKey
} from "./agentTranscriptModel";
describe("buildAgentTranscriptTurnGroups", () => {
it("keeps a turnless Goal control inside one surrounding Turn presentation group", () => {
const rows = [row("turn-1"), goalControlRow(), row("turn-1")];
const groups = buildAgentTranscriptTurnGroups(
rows,
rows.map(transcriptRowKey)
);
expect(groups).toHaveLength(1);
expect(groups[0]?.key).toBe("turn-1");
expect(groups[0]?.turnId).toBe("turn-1");
expect(groups[0]?.rows.map(({ row: item }) => item.kind)).toEqual([
"message",
"goal-control",
"message"
]);
expect(groups[0]?.rows[1]?.row.turnId).toBeNull();
});
it("keeps a Goal control between different Turns independent", () => {
const rows = [row("turn-1"), goalControlRow(), row("turn-2")];
const groups = buildAgentTranscriptTurnGroups(
rows,
rows.map(transcriptRowKey)
);
expect(groups.map((group) => group.key)).toEqual([
"turn-1",
"orphan:goal-control:clear",
"turn-2"
]);
expect(groups[1]?.turnId).toBeNull();
});
});
describe("findTurnDividerRowIndexes", () => {
const turnIndexes = new Map([
["turn-1", 0],
["turn-2", 1]
]);
it("keeps the normal divider between turns", () => {
expect([
...findTurnDividerRowIndexes(turnIndexes, [row("turn-1"), row("turn-2")])
]).toEqual([1]);
});
it("does not stack a turn divider immediately after a semantic boundary", () => {
expect([
...findTurnDividerRowIndexes(turnIndexes, [
row("turn-1", "turn-boundary"),
row("turn-2")
])
]).toEqual([]);
});
it("keeps the divider for running progress and non-adjacent boundaries", () => {
expect([
...findTurnDividerRowIndexes(turnIndexes, [
row("turn-1", "specific-progress"),
row("turn-2")
])
]).toEqual([1]);
expect([
...findTurnDividerRowIndexes(turnIndexes, [
row("turn-1", "turn-boundary"),
row("turn-1"),
row("turn-2")
])
]).toEqual([2]);
});
});
describe("summarizeUserMessageRow", () => {
it("keeps a quote-only selected-text row addressable by the transcript locator", () => {
const selectedOnlyRow: AgentTranscriptRowVM = {
kind: "message",
id: "row:user:selection-only",
turnId: "turn-selection-only",
speaker: "user",
messages: [
{
kind: "message-content",
id: "message:user:selection-only",
turnId: "turn-selection-only",
body: "",
presentationKind: "content",
contentKind: "selected-text",
selectedText: {
count: 1,
texts: ["Selected source text"]
},
occurredAtUnixMs: 1
}
],
thinking: [],
occurredAtUnixMs: 1
};
expect(
summarizeUserMessageRow(
selectedOnlyRow as Extract<AgentTranscriptRowVM, { kind: "message" }>
)
).toBe("Selected source text");
});
});
function row(
turnId: string,
presentationKind: AgentTranscriptPresentationKind = "content"
): AgentTranscriptRowVM {
return {
kind: "message",
id: `row:${turnId}:${presentationKind}`,
turnId,
speaker: "assistant",
messages: [
{
kind: "message-content",
id: `message:${turnId}:${presentationKind}`,
turnId,
body: presentationKind,
presentationKind,
occurredAtUnixMs: 1
}
],
thinking: [],
occurredAtUnixMs: 1
};
}
function goalControlRow(): AgentTranscriptRowVM {
return {
kind: "goal-control",
id: "goal-control:clear",
turnId: null,
action: "clear",
body: "/goal clear",
occurredAtUnixMs: 2
};
}
function userRow(turnId: string): AgentTranscriptRowVM {
return {
kind: "message",
id: `row:user:${turnId}`,
turnId,
speaker: "user",
messages: [
{
kind: "message-content",
id: `message:user:${turnId}`,
turnId,
body: "user question",
presentationKind: "content",
occurredAtUnixMs: 1
}
],
thinking: [],
occurredAtUnixMs: 1
};
}
function toolGroupRow(id: string, turnId: string): AgentTranscriptRowVM {
return {
kind: "tool-group",
id: `tool-group:${id}`,
turnId,
grouped: true,
calls: [],
entries: [],
occurredAtUnixMs: 1
};
}
describe("buildAgentParticipantTurnProjection", () => {
it("starts a presentation turn at each user message", () => {
const projection = buildAgentParticipantTurnProjection([
userRow("turn-1"),
row("turn-1"),
userRow("turn-2"),
row("turn-2")
]);
expect([...projection.dividerRowIndexes]).toEqual([2]);
expect([...projection.turnIndexByRowIndex.values()]).toEqual([0, 0, 1, 1]);
});
it("does not split a reply when its canonical turn id changes", () => {
const projection = buildAgentParticipantTurnProjection([
userRow("turn-1"),
row("turn-1"),
row("turn-recovery")
]);
expect([...projection.dividerRowIndexes]).toEqual([]);
expect([...projection.turnIndexByRowIndex.values()]).toEqual([0, 0, 0]);
});
it("starts a new turn when user messages are adjacent", () => {
const projection = buildAgentParticipantTurnProjection([
userRow("turn-1"),
userRow("turn-2")
]);
expect([...projection.dividerRowIndexes]).toEqual([1]);
expect([...projection.turnIndexByRowIndex.values()]).toEqual([0, 1]);
});
});
describe("attachLeadingToolRowsToFollowingMessages", () => {
it("moves tool-group rows into the assistant message that follows them", () => {
const rows = [
row("turn-1"),
toolGroupRow("a", "turn-1"),
toolGroupRow("b", "turn-1"),
row("turn-1")
];
const result = attachLeadingToolRowsToFollowingMessages(rows);
expect(result).toHaveLength(2);
expect(result[0]?.kind).toBe("message");
const merged = result[1];
expect(merged?.kind).toBe("message");
if (merged?.kind !== "message") {
throw new Error("Expected a message row");
}
expect(merged.leadingToolRows?.map((toolRow) => toolRow.id)).toEqual([
"tool-group:a",
"tool-group:b"
]);
});
it("does not attach tool-group rows to user messages", () => {
const rows = [toolGroupRow("a", "turn-1"), userRow("turn-1")];
const result = attachLeadingToolRowsToFollowingMessages(rows);
expect(result.map((item) => item.kind)).toEqual(["tool-group", "message"]);
});
it("keeps trailing tool-group rows standalone", () => {
const rows = [row("turn-1"), toolGroupRow("a", "turn-1")];
const result = attachLeadingToolRowsToFollowingMessages(rows);
expect(result.map((item) => item.kind)).toEqual(["message", "tool-group"]);
});
});
@@ -0,0 +1,364 @@
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]);
}
@@ -0,0 +1,562 @@
import { describe, expect, it } from "vitest";
import type { AgentActivityTurn } from "@tutti-os/agent-activity-core";
import type { AgentTranscriptRowVM } from "../contracts/agentTranscriptRowVM";
import type { AgentTranscriptTurnGroup } from "./agentTranscriptModel";
import {
buildAgentTurnWorkSectionModel as buildOptionalAgentTurnWorkSectionModel,
formatAgentTurnDuration,
resolveAgentTurnTiming
} from "./agentTurnWorkSectionModel";
describe("agentTurnWorkSectionModel", () => {
it("uses canonical turn timestamps for live and settled elapsed time", () => {
expect(
resolveAgentTurnTiming(canonicalTurn({ phase: "running" }), true)
).toEqual({ kind: "live", startedAtUnixMs: 5_000 });
expect(
resolveAgentTurnTiming(canonicalTurn({ phase: "running" }), false)
).toBeNull();
expect(
resolveAgentTurnTiming(canonicalTurn({ phase: "waiting" }), true)
).toEqual({ kind: "live", startedAtUnixMs: 5_000 });
expect(
resolveAgentTurnTiming(canonicalTurn({ phase: "submitted" }), true)
).toEqual({ kind: "live", startedAtUnixMs: 5_000 });
expect(
resolveAgentTurnTiming(canonicalTurn({ phase: "settling" }), true)
).toEqual({ kind: "live", startedAtUnixMs: 5_000 });
expect(
resolveAgentTurnTiming(
canonicalTurn({
phase: "settled",
outcome: "completed",
settledAtUnixMs: 127_999
}),
false
)
).toEqual({ kind: "settled", elapsedSeconds: 122 });
expect(
resolveAgentTurnTiming(
canonicalTurn({ startedAtUnixMs: Number.NaN }),
true
)
).toBeNull();
expect(
resolveAgentTurnTiming(
canonicalTurn({ phase: "settled", settledAtUnixMs: 4_999 }),
false
)
).toBeNull();
});
it("counts settled turn time from client submission", () => {
const prompt = {
...message("Please fix it", null),
occurredAtUnixMs: 2_000,
sourceTimelineItems: [
{
id: 1,
agentSessionId: "session-1",
eventId: "user-1",
actorType: "user",
actorId: "user",
itemType: "message",
payload: { clientSubmittedAtUnixMs: 1_000 },
occurredAtUnixMs: 2_000
}
]
};
const reply = message("Final answer", "Final answer", true);
const model = buildAgentTurnWorkSectionModel(
turnGroup([
userRow({ occurredAtUnixMs: 2_000, messages: [prompt] }),
assistantRow({ occurredAtUnixMs: 10_000, messages: [reply] })
]),
canonicalTurn({
phase: "settled",
outcome: "completed",
startedAtUnixMs: 6_000,
settledAtUnixMs: 13_000
})
);
expect(model.timing).toEqual({ kind: "settled", elapsedSeconds: 12 });
});
it("formats seconds and minute boundaries without a zero-second suffix", () => {
expect(formatAgentTurnDuration(45)).toEqual({
kind: "seconds",
seconds: 45
});
expect(formatAgentTurnDuration(60)).toEqual({
kind: "minutes",
minutes: 1
});
expect(formatAgentTurnDuration(127)).toEqual({
kind: "minutes-seconds",
minutes: 2,
seconds: 7
});
});
it("does not create a disclosure model without canonical timing", () => {
expect(
buildOptionalAgentTurnWorkSectionModel(turnGroup([userRow()]), null)
).toBeNull();
expect(
buildOptionalAgentTurnWorkSectionModel(
turnGroup([userRow()]),
canonicalTurn({ phase: "running" }),
false
)
).toBeNull();
});
it("keeps the final assistant copy target visible and moves work around it", () => {
const model = buildAgentTurnWorkSectionModel(
turnGroup([
userRow(),
assistantRow({
id: "assistant-row",
messages: [
message("draft", null),
message("final", "Final answer", true),
message("epilogue", null)
],
thinking: [thinking("Inspecting files")]
}),
toolRow(),
summaryRow()
]),
canonicalTurn({
phase: "settled",
outcome: "completed",
settledAtUnixMs: 15_000
}),
false,
{ collapseIntermediateAssistantReplies: true }
);
expect(model.leadingRows).toHaveLength(1);
expect(model.sections.map((section) => section.kind)).toEqual([
"work",
"visible",
"work",
"visible"
]);
expect(model.collapseEligible).toBe(true);
const finalRow = model.sections[1]?.rows[0]?.row;
expect(finalRow?.kind).toBe("message");
if (finalRow?.kind === "message") {
expect(finalRow.id).toBe("assistant-row");
expect(finalRow.messages.map((item) => item.body)).toEqual(["final"]);
expect(finalRow.thinking).toEqual([]);
}
expect(model.sections[0]?.rows[0]?.renderKey).toBe(
"assistant-row:turn-work-before"
);
expect(model.sections[1]?.rows[0]?.renderKey).toBe(
"assistant-row:turn-final"
);
expect(model.sections[2]?.rows[0]?.renderKey).toBe(
"assistant-row:turn-work-after"
);
const afterFinalRow = model.sections[2]?.rows[0]?.row;
expect(afterFinalRow?.kind).toBe("message");
if (afterFinalRow?.kind === "message") {
expect(afterFinalRow.messages.map((item) => item.body)).toEqual([
"epilogue"
]);
}
expect(model.sections[3]?.rows.map(({ row }) => row.id)).toEqual([
"summary"
]);
});
it("uses an explicit final-text marker instead of copy availability", () => {
const model = buildAgentTurnWorkSectionModel(
turnGroup([
userRow(),
toolRow(),
assistantRow({
messages: [message("Visible final answer", null, true)]
})
]),
canonicalTurn({
phase: "settled",
outcome: "completed",
settledAtUnixMs: 15_000
}),
false,
{ collapseIntermediateAssistantReplies: true }
);
expect(model.collapseEligible).toBe(true);
expect(model.sections).toHaveLength(2);
expect(model.sections[1]?.rows[0]?.row.kind).toBe("message");
});
it("preserves interleaved user and assistant chronology in ordered sections", () => {
const model = buildAgentTurnWorkSectionModel(
turnGroup([
userRow(),
assistantRow({
id: "assistant-1",
messages: [message("Earlier answer", null)]
}),
userRow({
id: "user-2",
messages: [message("Follow-up", null)],
occurredAtUnixMs: 9_000
}),
toolRow(),
assistantRow({
id: "assistant-2",
messages: [message("Final answer", "Final answer", true)]
})
]),
canonicalTurn({
phase: "settled",
outcome: "completed",
settledAtUnixMs: 15_000
}),
false,
{ collapseIntermediateAssistantReplies: true }
);
expect(model.leadingRows.map(({ row }) => row.id)).toEqual(["user-row"]);
expect(
model.sections.map((section) => ({
kind: section.kind,
rowIds: section.rows.map(({ row }) => row.id)
}))
).toEqual([
{ kind: "work", rowIds: ["assistant-1"] },
{ kind: "visible", rowIds: ["user-2"] },
{ kind: "work", rowIds: ["tools"] },
{ kind: "visible", rowIds: ["assistant-2"] }
]);
});
it("collapses all assistant work before the final answer for Tutti-created conversations", () => {
const model = buildAgentTurnWorkSectionModel(
turnGroup([
userRow(),
assistantRow({
id: "assistant-progress",
messages: [
{
...message("Compacting context", null),
presentationKind: "specific-progress"
},
message("Earlier answer", null)
],
thinking: [thinking("Inspecting files")]
}),
assistantRow({
id: "assistant-final",
messages: [message("Final answer", "Final answer", true)]
})
]),
canonicalTurn({
phase: "settled",
outcome: "completed",
settledAtUnixMs: 15_000
}),
false,
{ collapseIntermediateAssistantReplies: true }
);
expect(
model.sections.map((section) => ({
kind: section.kind,
bodies: section.rows.flatMap(({ row }) =>
row.kind === "message"
? [
...row.thinking.map((item) => item.body),
...row.messages.map((item) => item.body)
]
: []
)
}))
).toEqual([
{
kind: "work",
bodies: ["Inspecting files", "Compacting context", "Earlier answer"]
},
{
kind: "visible",
bodies: ["Final answer"]
}
]);
expect(model.collapseEligible).toBe(true);
});
it("preserves ordinary assistant replies for imported conversation history", () => {
const model = buildAgentTurnWorkSectionModel(
turnGroup([
userRow(),
assistantRow({
id: "assistant-earlier",
messages: [message("Earlier answer", null)]
}),
toolRow(),
assistantRow({
id: "assistant-final",
messages: [message("Final answer", "Final answer", true)]
})
]),
canonicalTurn({
phase: "settled",
outcome: "completed",
settledAtUnixMs: 15_000
}),
false,
{ collapseIntermediateAssistantReplies: false }
);
expect(
model.sections.map((section) => ({
kind: section.kind,
bodies: section.rows.flatMap(({ row }) =>
row.kind === "message" ? row.messages.map((item) => item.body) : []
)
}))
).toEqual([
{ kind: "visible", bodies: ["Earlier answer"] },
{ kind: "work", bodies: [] },
{ kind: "visible", bodies: ["Final answer"] }
]);
});
it("fails open when no visible final text is explicitly marked", () => {
const copyOnly = buildAgentTurnWorkSectionModel(
turnGroup([
userRow(),
toolRow(),
assistantRow({ messages: [message("Copy only", "Copy only")] })
]),
canonicalTurn({
phase: "settled",
outcome: "completed",
settledAtUnixMs: 15_000
})
);
const blankMarked = buildAgentTurnWorkSectionModel(
turnGroup([
userRow(),
toolRow(),
assistantRow({ messages: [message(" ", null, true)] })
]),
canonicalTurn({
phase: "settled",
outcome: "completed",
settledAtUnixMs: 15_000
})
);
expect(copyOnly.collapseEligible).toBe(false);
expect(blankMarked.collapseEligible).toBe(false);
});
it("does not offer auto-collapse for abnormal or artifact-first turns", () => {
const rows = [userRow(), toolRow(), assistantRow(), summaryRow()];
expect(
buildAgentTurnWorkSectionModel(
turnGroup(rows),
canonicalTurn({
phase: "settled",
outcome: "failed",
settledAtUnixMs: 15_000
})
).collapseEligible
).toBe(false);
expect(
buildAgentTurnWorkSectionModel(
turnGroup([...rows, generatedImageRow()]),
canonicalTurn({
phase: "settled",
outcome: "completed",
settledAtUnixMs: 15_000
})
).collapseEligible
).toBe(false);
expect(
buildAgentTurnWorkSectionModel(
turnGroup([
userRow(),
assistantRow({
messages: [
{
...message("Config invalid", null),
visibleError: {
code: "invalid_config",
phase: null,
provider: "codex",
detail: "Config invalid",
retryable: false
}
},
message("final", "Final answer", true)
]
})
]),
canonicalTurn({
phase: "settled",
outcome: "completed",
settledAtUnixMs: 15_000
})
).collapseEligible
).toBe(false);
});
it("shows timing without a disclosure when a completed turn has only a final answer", () => {
const model = buildAgentTurnWorkSectionModel(
turnGroup([userRow(), assistantRow()]),
canonicalTurn({
phase: "settled",
outcome: "completed",
settledAtUnixMs: 15_000
})
);
expect(model.timing).toEqual({ kind: "settled", elapsedSeconds: 10 });
expect(model.collapseEligible).toBe(false);
expect(model.sections).toHaveLength(1);
expect(model.sections[0]?.kind).toBe("visible");
});
});
function canonicalTurn(
overrides: Partial<AgentActivityTurn> = {}
): AgentActivityTurn {
return {
agentSessionId: "session-1",
origin: "user_prompt",
phase: "running",
startedAtUnixMs: 5_000,
turnId: "turn-1",
updatedAtUnixMs: 6_000,
...overrides
};
}
function buildAgentTurnWorkSectionModel(
...args: Parameters<typeof buildOptionalAgentTurnWorkSectionModel>
): NonNullable<ReturnType<typeof buildOptionalAgentTurnWorkSectionModel>> {
const model = buildOptionalAgentTurnWorkSectionModel(...args);
expect(model).not.toBeNull();
return model!;
}
function turnGroup(rows: AgentTranscriptRowVM[]): AgentTranscriptTurnGroup {
return {
key: "turn-1",
turnId: "turn-1",
rows: rows.map((row, rowIndex) => ({ row, rowIndex }))
};
}
function userRow(
overrides: Partial<Extract<AgentTranscriptRowVM, { kind: "message" }>> = {}
): AgentTranscriptRowVM {
return {
kind: "message",
id: "user-row",
turnId: "turn-1",
speaker: "user",
messages: [message("Please fix it", null)],
thinking: [],
occurredAtUnixMs: 5_000,
...overrides
};
}
function assistantRow(
overrides: Partial<Extract<AgentTranscriptRowVM, { kind: "message" }>> = {}
): AgentTranscriptRowVM {
return {
kind: "message",
id: "assistant-row",
turnId: "turn-1",
speaker: "assistant",
messages: [message("Final answer", "Final answer", true)],
thinking: [],
occurredAtUnixMs: 14_000,
...overrides
};
}
function message(
body: string,
copyText: string | null,
isTurnFinalText = false
) {
return {
kind: "message-content" as const,
id: `message:${body}`,
turnId: "turn-1",
body,
presentationKind: "content" as const,
copyText,
...(isTurnFinalText ? { isTurnFinalText: true as const } : {}),
occurredAtUnixMs: 14_000
};
}
function thinking(body: string) {
return {
kind: "thinking-content" as const,
id: `thinking:${body}`,
turnId: "turn-1",
body,
occurredAtUnixMs: 7_000
};
}
function toolRow(): AgentTranscriptRowVM {
return {
kind: "tool-group",
id: "tools",
turnId: "turn-1",
grouped: true,
calls: [],
entries: [],
occurredAtUnixMs: 8_000
};
}
function summaryRow(): AgentTranscriptRowVM {
return {
kind: "turn-summary",
id: "summary",
turnId: "turn-1",
files: [],
fileCount: 0,
modifiedCount: 0,
createdCount: 0,
occurredAtUnixMs: 15_000
};
}
function generatedImageRow(): AgentTranscriptRowVM {
return {
kind: "generated-image",
id: "image",
turnId: "turn-1",
sourceCallId: "image-call",
uri: "file:///tmp/image.png",
mimeType: "image/png",
prompt: "A test image",
occurredAtUnixMs: 12_000
};
}
@@ -0,0 +1,479 @@
import type { AgentActivityTurn } from "@tutti-os/agent-activity-core";
import type { AgentGUIObservationGap } from "../../../types";
import type {
AgentMessageContentVM,
AgentMessageRowVM
} from "../contracts/agentMessageRowVM";
import type { AgentTranscriptTurnGroup } from "./agentTranscriptModel";
export type AgentTurnTiming =
| {
kind: "live";
startedAtUnixMs: number;
frozenAtUnixMs?: number | null;
observationGapPresentationState?: NonNullable<
AgentGUIObservationGap["presentationState"]
>;
}
| { kind: "settled"; elapsedSeconds: number };
export type AgentTurnDuration =
| { kind: "seconds"; seconds: number }
| { kind: "minutes"; minutes: number }
| { kind: "minutes-seconds"; minutes: number; seconds: number };
export type AgentTurnWorkSectionRow =
AgentTranscriptTurnGroup["rows"][number] & {
renderKey?: string;
};
export interface AgentTurnWorkSectionSegment {
kind: "visible" | "work";
rows: AgentTurnWorkSectionRow[];
}
export interface AgentTurnWorkSectionModel {
timing: AgentTurnTiming;
leadingRows: AgentTurnWorkSectionRow[];
sections: AgentTurnWorkSectionSegment[];
collapseEligible: boolean;
}
/**
* Selects at most one participant header per speaker in each presentation
* turn. Completed collapsed turns prefer a visible message so the Agent header
* does not disappear into the hidden work section.
*/
export function findParticipantHeaderRenderKeys(
groups: readonly AgentTranscriptTurnGroup[],
rowKeys: readonly string[],
modelByGroupKey: ReadonlyMap<string, AgentTurnWorkSectionModel | null>,
participantTurnIndexByRowIndex: ReadonlyMap<number, number>
): ReadonlySet<string> {
const headerCandidateByTurn = new Map<
number,
Map<
AgentMessageRowVM["speaker"],
{ renderKey: string; visibilityPriority: number }
>
>();
for (const group of groups) {
const model = modelByGroupKey.get(group.key);
const renderedRows: ReadonlyArray<{
entry: AgentTurnWorkSectionRow;
visibilityPriority: number;
}> = model
? [
...model.leadingRows.map((entry) => ({
entry,
visibilityPriority: 0
})),
...model.sections.flatMap((section) =>
section.rows.map((entry) => ({
entry,
visibilityPriority:
model.collapseEligible && section.kind === "work" ? 1 : 0
}))
)
]
: group.rows.map((entry) => ({ entry, visibilityPriority: 0 }));
for (const { entry, visibilityPriority } of renderedRows) {
const row = entry.row;
if (row.kind !== "message" || row.messages.length === 0) {
continue;
}
const participantTurnIndex =
participantTurnIndexByRowIndex.get(entry.rowIndex) ?? entry.rowIndex;
let candidateBySpeaker = headerCandidateByTurn.get(participantTurnIndex);
if (!candidateBySpeaker) {
candidateBySpeaker = new Map();
headerCandidateByTurn.set(participantTurnIndex, candidateBySpeaker);
}
const currentCandidate = candidateBySpeaker.get(row.speaker);
if (
currentCandidate &&
currentCandidate.visibilityPriority <= visibilityPriority
) {
continue;
}
candidateBySpeaker.set(row.speaker, {
renderKey: entry.renderKey ?? rowKeys[entry.rowIndex] ?? row.id,
visibilityPriority
});
}
}
return new Set(
[...headerCandidateByTurn.values()].flatMap((candidateBySpeaker) =>
[...candidateBySpeaker.values()].map((candidate) => candidate.renderKey)
)
);
}
interface AgentTurnWorkSectionOptions {
collapseIntermediateAssistantReplies?: boolean;
liveFrozenAtUnixMs?: number | null;
liveObservationGapPresentationState?:
| AgentGUIObservationGap["presentationState"]
| null;
}
export function resolveAgentTurnTiming(
turn: AgentActivityTurn | null | undefined,
isActiveTurn: boolean,
submittedAtUnixMs?: number | null
): AgentTurnTiming | null {
if (!turn || !Number.isFinite(turn.startedAtUnixMs)) {
return null;
}
const timingStartedAtUnixMs =
Number.isFinite(submittedAtUnixMs) &&
(submittedAtUnixMs as number) <= turn.startedAtUnixMs
? (submittedAtUnixMs as number)
: turn.startedAtUnixMs;
if (turn.phase !== "settled") {
return isActiveTurn
? { kind: "live", startedAtUnixMs: timingStartedAtUnixMs }
: null;
}
const endUnixMs = turn.settledAtUnixMs;
if (
!Number.isFinite(endUnixMs) ||
(endUnixMs as number) < timingStartedAtUnixMs
) {
return null;
}
return {
kind: "settled",
elapsedSeconds: Math.max(
0,
Math.floor(((endUnixMs as number) - timingStartedAtUnixMs) / 1_000)
)
};
}
export function formatAgentTurnDuration(
elapsedSeconds: number
): AgentTurnDuration {
const safeSeconds = Math.max(0, Math.floor(elapsedSeconds));
if (safeSeconds < 60) {
return { kind: "seconds", seconds: safeSeconds };
}
const minutes = Math.floor(safeSeconds / 60);
const seconds = safeSeconds % 60;
if (seconds === 0) {
return { kind: "minutes", minutes };
}
return { kind: "minutes-seconds", minutes, seconds };
}
export function buildAgentTurnWorkSectionModel(
group: AgentTranscriptTurnGroup,
turn: AgentActivityTurn | null | undefined,
isActiveTurn = false,
options: AgentTurnWorkSectionOptions = {}
): AgentTurnWorkSectionModel | null {
const leadingRowCount = countLeadingUserRows(group.rows);
const leadingRows = group.rows.slice(0, leadingRowCount);
const submittedAtUnixMs = resolveTurnSubmittedAtUnixMs(
leadingRows,
turn?.startedAtUnixMs
);
const timing = resolveAgentTurnTiming(turn, isActiveTurn, submittedAtUnixMs);
if (!timing) {
return null;
}
const finalTarget = findFinalAssistantTextTarget(group.rows);
const sections = buildOrderedSections(
group.rows,
leadingRowCount,
finalTarget,
options.collapseIntermediateAssistantReplies === true
);
const hasHiddenWork = sections.some(
(section) => section.kind === "work" && section.rows.length > 0
);
const collapseEligible =
finalTarget !== null &&
turn?.phase === "settled" &&
turn.outcome === "completed" &&
hasHiddenWork &&
!groupContainsBlockingMessage(group) &&
!group.rows.some(({ row }) => row.kind === "generated-image");
return {
timing:
timing.kind === "live"
? {
...timing,
...(Number.isFinite(options.liveFrozenAtUnixMs) &&
(options.liveFrozenAtUnixMs as number) >= timing.startedAtUnixMs
? { frozenAtUnixMs: options.liveFrozenAtUnixMs as number }
: {}),
...(options.liveObservationGapPresentationState
? {
observationGapPresentationState:
options.liveObservationGapPresentationState
}
: {})
}
: timing,
leadingRows,
sections,
collapseEligible
};
}
function resolveTurnSubmittedAtUnixMs(
leadingRows: readonly AgentTurnWorkSectionRow[],
fallbackUnixMs: number | null | undefined
): number | null {
const exactTimestamps = leadingRows.flatMap(({ row }) => {
if (row.kind !== "message" || row.speaker !== "user") {
return [];
}
return row.messages.flatMap((message) =>
(message.sourceTimelineItems ?? []).flatMap((item) => {
const value = item.payload?.clientSubmittedAtUnixMs;
return validUnixMs(value) ? [value] : [];
})
);
});
const exact = minimumTimestamp(exactTimestamps);
if (exact !== null) {
return exact;
}
const projected = minimumTimestamp(
leadingRows.flatMap(({ row }) =>
row.kind === "message" && row.speaker === "user"
? [
row.occurredAtUnixMs,
...row.messages.map((message) => message.occurredAtUnixMs)
]
: []
)
);
return projected ?? (validUnixMs(fallbackUnixMs) ? fallbackUnixMs : null);
}
function minimumTimestamp(
values: readonly (number | null | undefined)[]
): number | null {
const valid = values.filter(validUnixMs);
return valid.length > 0 ? Math.min(...valid) : null;
}
function validUnixMs(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
function countLeadingUserRows(
rows: readonly AgentTurnWorkSectionRow[]
): number {
let count = 0;
while (isUserMessageRow(rows[count]?.row)) {
count += 1;
}
return count;
}
function buildOrderedSections(
rows: readonly AgentTurnWorkSectionRow[],
startIndex: number,
finalTarget: { rowIndex: number; messageIndex: number } | null,
collapseIntermediateAssistantReplies: boolean
): AgentTurnWorkSectionSegment[] {
const sections: AgentTurnWorkSectionSegment[] = [];
for (let rowIndex = startIndex; rowIndex < rows.length; rowIndex += 1) {
const entry = rows[rowIndex]!;
if (entry.row.kind === "message" && entry.row.speaker === "assistant") {
if (!collapseIntermediateAssistantReplies) {
appendAssistantMessageSections(sections, entry);
} else if (finalTarget?.rowIndex === rowIndex) {
appendFinalAssistantSections(sections, entry, finalTarget.messageIndex);
} else {
appendSectionRow(sections, "work", entry);
}
continue;
}
appendSectionRow(
sections,
isExplicitWorkRow(entry.row) ? "work" : "visible",
entry
);
}
return sections;
}
function appendFinalAssistantSections(
sections: AgentTurnWorkSectionSegment[],
sourceEntry: AgentTurnWorkSectionRow,
finalMessageIndex: number
): void {
const sourceRow = sourceEntry.row as AgentMessageRowVM;
const messagesBeforeFinal = sourceRow.messages.slice(0, finalMessageIndex);
const messagesAfterFinal = sourceRow.messages.slice(finalMessageIndex + 1);
if (sourceRow.thinking.length > 0 || messagesBeforeFinal.length > 0) {
appendSectionRow(sections, "work", {
...sourceEntry,
renderKey: `${sourceRow.id}:turn-work-before`,
row: cloneAssistantRow(sourceRow, messagesBeforeFinal, sourceRow.thinking)
});
}
appendSectionRow(sections, "visible", {
...sourceEntry,
renderKey: `${sourceRow.id}:turn-final`,
row: cloneAssistantRow(
sourceRow,
[sourceRow.messages[finalMessageIndex]!],
[]
)
});
if (messagesAfterFinal.length > 0) {
appendSectionRow(sections, "work", {
...sourceEntry,
renderKey: `${sourceRow.id}:turn-work-after`,
row: cloneAssistantRow(sourceRow, messagesAfterFinal, [])
});
}
}
function appendAssistantMessageSections(
sections: AgentTurnWorkSectionSegment[],
sourceEntry: AgentTurnWorkSectionRow
): void {
const sourceRow = sourceEntry.row as AgentMessageRowVM;
const parts: Array<{
kind: AgentTurnWorkSectionSegment["kind"];
messages: AgentMessageContentVM[];
thinking: AgentMessageRowVM["thinking"];
}> = [];
if (sourceRow.thinking.length > 0) {
parts.push({ kind: "work", messages: [], thinking: sourceRow.thinking });
}
for (const message of sourceRow.messages) {
const kind = isExplicitWorkMessage(message) ? "work" : "visible";
const previous = parts.at(-1);
if (previous?.kind === kind && previous.thinking.length === 0) {
previous.messages.push(message);
continue;
}
parts.push({ kind, messages: [message], thinking: [] });
}
if (parts.length === 0) {
appendSectionRow(sections, "visible", sourceEntry);
return;
}
if (parts.length === 1 && sourceRow.thinking.length === 0) {
appendSectionRow(sections, parts[0]!.kind, sourceEntry);
return;
}
parts.forEach((part, partIndex) => {
appendSectionRow(sections, part.kind, {
...sourceEntry,
renderKey: `${sourceRow.id}:turn-${part.kind}-${partIndex}`,
row: cloneAssistantRow(sourceRow, part.messages, part.thinking)
});
});
}
function appendSectionRow(
sections: AgentTurnWorkSectionSegment[],
kind: AgentTurnWorkSectionSegment["kind"],
row: AgentTurnWorkSectionRow
): void {
const previous = sections.at(-1);
if (previous?.kind === kind) {
previous.rows.push(row);
return;
}
sections.push({ kind, rows: [row] });
}
function findFinalAssistantTextTarget(
rows: readonly AgentTurnWorkSectionRow[]
): { rowIndex: number; messageIndex: number } | null {
for (let rowIndex = rows.length - 1; rowIndex >= 0; rowIndex -= 1) {
const row = rows[rowIndex]?.row;
if (row?.kind !== "message" || row.speaker !== "assistant") {
continue;
}
for (
let messageIndex = row.messages.length - 1;
messageIndex >= 0;
messageIndex -= 1
) {
const message = row.messages[messageIndex];
if (
message?.isTurnFinalText &&
message.body.trim() &&
message.contentKind !== "image-grid" &&
!message.visibleError &&
!message.systemNotice
) {
return { rowIndex, messageIndex };
}
}
}
return null;
}
function groupContainsBlockingMessage(
group: AgentTranscriptTurnGroup
): boolean {
return group.rows.some(
({ row }) =>
row.kind === "message" &&
row.messages.some((message) =>
Boolean(
message.visibleError ||
(message.systemNotice && message.presentationKind === "content")
)
)
);
}
function isExplicitWorkRow(row: AgentTurnWorkSectionRow["row"]): boolean {
return row.kind === "tool-group" || row.kind === "processing";
}
function isExplicitWorkMessage(message: AgentMessageContentVM): boolean {
return (
message.presentationKind === "specific-progress" ||
message.presentationKind === "turn-boundary"
);
}
function isUserMessageRow(
row: AgentTurnWorkSectionRow["row"] | undefined
): row is AgentMessageRowVM {
return row?.kind === "message" && row.speaker === "user";
}
function cloneAssistantRow(
source: AgentMessageRowVM,
messages: AgentMessageContentVM[],
thinking: AgentMessageRowVM["thinking"]
): AgentMessageRowVM {
return {
...source,
messages,
thinking
};
}
@@ -0,0 +1,331 @@
import type { JSX } from "react";
import type { AgentApprovalItemVM } from "../contracts/agentApprovalItemVM";
import { translate } from "../../../i18n/index";
import {
Spinner,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@tutti-os/ui-system";
import { normalizeApprovalOptionToken } from "../approvalOptionPresentation";
import {
getPromptToolDetails,
type PromptToolDetail
} from "../promptToolDetails";
import styles from "../../../agent-gui/agentGuiNode/AgentGUIConversation.styles";
const COMMAND_TOOLTIP_DELAY_MS = 1000;
export function isEnterLikeKey(event: KeyboardEvent): boolean {
return (
event.key === "Enter" ||
event.code === "Enter" ||
event.code === "NumpadEnter"
);
}
export function isEditableKeyboardTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
const tagName = target.tagName.toLowerCase();
return (
tagName === "input" ||
tagName === "textarea" ||
tagName === "select" ||
target.isContentEditable
);
}
export function approvalOptionShortcutLabel(
optionIndex: number,
isDarwin: boolean
): string | null {
if (optionIndex === 0) {
return translate("agentHost.agentGui.shortcutEnter");
}
if (optionIndex === 1) {
return isDarwin
? translate("agentHost.agentGui.shortcutCmdEnter")
: translate("agentHost.agentGui.shortcutCtrEnter");
}
return null;
}
export function isDarwinPlatform(platform: string | undefined): boolean {
if (platform) {
return platform === "darwin";
}
if (typeof navigator === "undefined") {
return false;
}
const userAgentPlatform =
"userAgentData" in navigator
? (
navigator as Navigator & {
userAgentData?: { platform?: string };
}
).userAgentData?.platform
: undefined;
const navigatorPlatform = userAgentPlatform ?? navigator.platform ?? "";
return /mac/i.test(navigatorPlatform);
}
export function InteractiveOptionSpinner(): JSX.Element {
"use memo";
return (
<Spinner
className={styles.interactiveOptionSpinner}
testId="agent-interactive-option-spinner"
/>
);
}
export function SendFilledIcon(): JSX.Element {
"use memo";
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path
d="M2.74311 8.80587C2.84592 8.40096 3.14571 8.08844 3.54551 7.97033L18.5197 3.51569C18.9336 3.39383 19.3809 3.5054 19.6881 3.81262C19.9951 4.11984 20.1076 4.56798 19.9857 4.9817L15.5311 19.9559C15.413 20.3557 15.1005 20.6555 14.6956 20.7583C14.2895 20.8597 13.869 20.7438 13.5721 20.4469L10.455 15.1823C10.8585 14.6483 12.1563 12.9094 14.3475 9.96528C14.6086 9.70419 14.6382 9.31168 14.4138 9.08692C14.1891 8.86221 13.796 8.8913 13.5348 9.15252L8.31088 13.0423L3.05316 9.92799C2.7562 9.63104 2.64049 9.21071 2.74311 8.80587Z"
fill="currentColor"
/>
</svg>
);
}
export function interactivePromptClassName(embedded: boolean): string {
return embedded
? `${styles.interactivePrompt} agent-gui-conversation__interactive-prompt--embedded`
: styles.interactivePrompt;
}
export function interactivePromptCardClassName(edgeGlow: boolean): string {
return edgeGlow
? `${styles.interactivePromptCard} agent-gui-edge-glow`
: styles.interactivePromptCard;
}
export interface LabeledPromptToolDetail {
kind: PromptToolDetail["kind"];
label: string;
value: string;
meta?: string;
}
export interface ApprovalPromptToolPresentation {
lead: string;
leadDetails: LabeledPromptToolDetail[];
cardDetails: LabeledPromptToolDetail[];
}
export function formatToolDetails(
input: Record<string, unknown> | null
): LabeledPromptToolDetail[] {
return getPromptToolDetails(input).map((detail) => ({
kind: detail.kind,
label: promptToolDetailLabel(detail.kind),
value: detail.value,
...(detail.meta ? { meta: detail.meta } : {})
}));
}
export function formatApprovalToolPresentation(
prompt: Pick<AgentApprovalItemVM, "approvalPurpose" | "input">,
labels: { approvalLead: string; fileChangeApprovalLead: string }
): ApprovalPromptToolPresentation {
const details = formatToolDetails(prompt.input);
const hasFileChanges = details.some((detail) => detail.kind === "files");
const isLeadDetail = (detail: LabeledPromptToolDetail): boolean =>
detail.kind === "reason" ||
(hasFileChanges && (detail.kind === "directory" || detail.kind === "path"));
const leadDetails = [
...details.filter((detail) => detail.kind === "reason"),
...details.filter(
(detail) => detail.kind !== "reason" && isLeadDetail(detail)
)
].filter(
(detail, index, candidates) =>
candidates.findIndex(
(candidate) =>
candidate.value === detail.value && candidate.meta === detail.meta
) === index
);
const purposeLeads = {
generic: labels.approvalLead,
"edit-files": labels.fileChangeApprovalLead
} satisfies Record<
NonNullable<AgentApprovalItemVM["approvalPurpose"]> | "generic",
string
>;
return {
lead: purposeLeads[prompt.approvalPurpose ?? "generic"],
leadDetails,
cardDetails: details.filter((detail) => !isLeadDetail(detail))
};
}
export function PromptDetailValue({
detail
}: {
detail: LabeledPromptToolDetail;
}): JSX.Element {
"use memo";
if (detail.kind !== "command") {
return (
<span className={styles.interactiveOptionDescription}>
{detail.value}
</span>
);
}
return (
<CommandTextWithTooltip
value={detail.value}
testId="agent-interactive-command-detail"
tooltipsEnabled
/>
);
}
export function CommandTextWithTooltip({
value,
testId,
tooltipsEnabled = true
}: {
value: string;
testId: string;
tooltipsEnabled?: boolean;
}): JSX.Element {
"use memo";
const content = (
<span
className={`${styles.interactiveOptionDescription} ${styles.interactiveOptionCommandDescription}`}
data-agent-interactive-command-detail={
testId === "agent-interactive-command-detail" ? "true" : undefined
}
data-agent-interactive-command-prefix-option={
testId === "agent-interactive-command-prefix-option"
? "true"
: undefined
}
>
{value}
</span>
);
if (!tooltipsEnabled) {
return content;
}
return (
<TooltipProvider delayDuration={COMMAND_TOOLTIP_DELAY_MS}>
<Tooltip>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent className={styles.interactiveOptionCommandTooltip}>
{value}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export function promptToolDetailLabel(kind: PromptToolDetail["kind"]): string {
switch (kind) {
case "command":
return translate("agentHost.agentTool.details.command");
case "directory":
return translate("agentHost.agentTool.details.scope");
case "files":
return translate("workspaceCanvas.nodeDockLabel.files");
case "mcp":
return translate("agentHost.agentTool.details.mcp");
case "path":
return translate("agentHost.agentTool.details.path");
case "prompt":
return translate("agentHost.agentTool.details.prompt");
case "query":
return translate("agentHost.agentTool.details.query");
case "reason":
return translate("agentHost.agentTool.details.summary");
case "url":
return translate("agentHost.agentTool.details.url");
}
}
export function isApprovalFeedbackOption(option: {
id: string;
kind: string;
}): boolean {
return (
isDenyApprovalOptionToken(option.id) ||
isDenyApprovalOptionToken(option.kind)
);
}
export function approvalFeedbackOptionId(
options: readonly { id: string; kind: string }[]
): string | null {
const explicitFeedbackOption = options.find((option) =>
isExplicitFeedbackDenyApprovalOption(option)
);
if (explicitFeedbackOption) {
return explicitFeedbackOption.id;
}
return options.find(isApprovalFeedbackOption)?.id ?? null;
}
export function isExplicitFeedbackDenyApprovalOption(option: {
id: string;
kind: string;
}): boolean {
for (const value of [option.id, option.kind]) {
switch (normalizeApprovalOptionToken(value ?? "")) {
case "denywithfeedback":
case "rejectwithfeedback":
return true;
default:
break;
}
}
return false;
}
export function isDenyApprovalOptionToken(
value: string | null | undefined
): boolean {
switch (normalizeApprovalOptionToken(value ?? "")) {
case "abort":
case "cancel":
case "cancelled":
case "canceled":
case "deny":
case "denied":
case "reject":
case "rejected":
case "rejectonce":
case "disallow":
case "decline":
case "declined":
case "no":
return true;
default:
return false;
}
}
export function stripPromptTitlePunctuation(value: string): string {
return value.trim().replace(/[.。]+$/u, "");
}
export function interactiveOptionLabel(
label: string,
description: string | null | undefined
): string {
const trimmedDescription = description?.trim();
return trimmedDescription ? `${label} ${trimmedDescription}` : label;
}
@@ -0,0 +1,153 @@
import type { JSX } from "react";
import { translate } from "../../../../i18n/index";
import type {
AgentToolCallVM,
AgentToolRendererKind
} from "../../contracts/agentToolCallVM";
import { AgentEditContent } from "./AgentEditContent";
import { AgentWebFetchContent } from "./AgentWebFetchContent";
import { AgentWebSearchContent } from "./AgentWebSearchContent";
import {
arrayValue,
objectValue,
stringValue,
ToolSection,
ToolMarkdownBlock,
type AgentToolRendererProps
} from "./agentToolContentShared";
import { formatToolDetails } from "../interactivePromptPresentation";
export function AgentApprovalContent({
call,
onLinkClick
}: AgentToolRendererProps): JSX.Element | null {
"use memo";
const previewCall = approvalPreviewCall(call);
const detailText = approvalDetailText(call.input);
if (!previewCall && !call.summary.trim() && !detailText) {
return null;
}
if (previewCall) {
switch (previewCall.rendererKind) {
case "web-fetch":
return (
<AgentWebFetchContent call={previewCall} onLinkClick={onLinkClick} />
);
case "web-search":
return (
<AgentWebSearchContent call={previewCall} onLinkClick={onLinkClick} />
);
default:
return (
<AgentEditContent call={previewCall} onLinkClick={onLinkClick} />
);
}
}
return (
<div className="workspace-agents-status-panel__detail-tool-body">
{call.summary.trim() ? (
<ToolMarkdownBlock content={call.summary} />
) : null}
{detailText ? (
<ToolSection title={translate("agentHost.agentTool.details.input")}>
<ToolMarkdownBlock content={detailText} />
</ToolSection>
) : null}
</div>
);
}
function approvalDetailText(
input: Record<string, unknown> | null
): string | null {
const details = formatToolDetails(input);
if (details.length === 0) {
return null;
}
return details
.map((detail) =>
[detail.label, detail.value, detail.meta]
.filter((value): value is string => Boolean(value?.trim()))
.join("\n")
)
.join("\n\n");
}
interface ApprovalPreviewKind {
rendererKind: AgentToolRendererKind;
toolName: string;
}
// The nested tool-call's `kind` follows the ACP vocabulary (edit/move/fetch/...)
// when the provider sends it (e.g. Codex); Claude Code omits `kind` and only
// sets `title`/`toolName` to the canonical tool name (e.g. "WebFetch"), so
// both vocabularies are matched here.
function approvalPreviewKindFor(
normalizedKind: string
): ApprovalPreviewKind | null {
switch (normalizedKind) {
case "edit":
case "move":
return { rendererKind: "edit", toolName: "Edit" };
case "fetch":
case "webfetch":
case "web_fetch":
case "web-fetch":
return { rendererKind: "web-fetch", toolName: "WebFetch" };
case "websearch":
case "web_search":
case "web-search":
return { rendererKind: "web-search", toolName: "WebSearch" };
default:
return null;
}
}
function approvalPreviewCall(call: AgentToolCallVM): AgentToolCallVM | null {
const toolCall = objectValue(call.input?.toolCall);
if (!toolCall) {
return null;
}
const normalizedKind = normalizeToolKind(
stringValue(toolCall.kind) ??
stringValue(toolCall.title) ??
stringValue(toolCall.toolName)
);
const preview = approvalPreviewKindFor(normalizedKind);
if (!preview) {
return null;
}
const input = objectValue(toolCall.input) ?? call.input;
const locations = arrayValue(toolCall.locations);
return {
kind: "tool-call",
id: `${call.id}:approval-preview`,
turnId: call.turnId,
name: stringValue(toolCall.title) ?? call.name,
toolName: preview.toolName,
callType: "tool",
status: stringValue(toolCall.status) ?? call.status,
statusKind: call.statusKind,
summary: "",
compactSummary: null,
payload: {
input,
locations
},
input,
output: null,
error: null,
metadata: null,
locations,
rendererKind: preview.rendererKind,
approval: null,
planMode: null,
askUserQuestion: null,
task: null,
occurredAtUnixMs: call.occurredAtUnixMs
};
}
function normalizeToolKind(value: string | null): string {
return (value ?? "").trim().toLowerCase();
}
@@ -0,0 +1,107 @@
import type { JSX } from "react";
import { MessageSquareMoreIcon } from "../../../../app/renderer/components/icons/MessageSquareMoreIcon";
import { translate } from "../../../../i18n/index";
import {
ToolMarkdownBlock,
ToolSection,
type AgentToolRendererProps
} from "./agentToolContentShared";
export function AgentAskUserQuestionContent({
call,
onLinkClick
}: AgentToolRendererProps): JSX.Element | null {
"use memo";
const questions = call.askUserQuestion?.questions ?? [];
const outputText = stringValue(call.output?.text);
if (questions.length === 0) {
return null;
}
return (
<div className="workspace-agents-status-panel__detail-tool-body">
<ToolSection title={translate("agentHost.agentTool.details.questions")}>
<div className="workspace-agents-status-panel__detail-tool-stack">
{questions.map((question) => {
const body =
question.question ||
question.header ||
translate("agentHost.agentTool.details.questionFallback");
const answer = formatAnswer(question.answer);
const questionKey =
question.id.trim() ||
`${question.header}:${question.question}:${question.options.map((option) => option.label).join("|")}`;
return (
<div
key={questionKey}
className="workspace-agents-status-panel__detail-tool-stack-item"
>
<div className="workspace-agents-status-panel__detail-tool-question">
{body}
</div>
{question.options.length > 0 ? (
<div className="workspace-agents-status-panel__detail-tool-stack">
{question.options.map((option) => (
<div
key={`${questionKey}-option-${option.label}:${option.description}`}
className="workspace-agents-status-panel__detail-tool-stack-item"
>
<div className="workspace-agents-status-panel__detail-tool-summary">
{option.label}
</div>
{option.description ? (
<div className="workspace-agents-status-panel__detail-tool-answer workspace-agents-status-panel__detail-tool-answer--muted">
{option.description}
</div>
) : null}
</div>
))}
</div>
) : null}
{answer ? (
<div className="workspace-agents-status-panel__detail-tool-answer">
{translate("agentHost.agentTool.details.answerPrefix", {
answer
})}
</div>
) : (
<div className="workspace-agents-status-panel__detail-tool-answer workspace-agents-status-panel__detail-tool-answer--muted inline-flex items-center gap-1.5">
<MessageSquareMoreIcon
size={14}
active
aria-hidden="true"
className="shrink-0"
/>
{translate("agentHost.agentTool.details.waitingForAnswer")}
</div>
)}
</div>
);
})}
</div>
</ToolSection>
{outputText ? (
<ToolSection title={translate("agentHost.agentTool.details.output")}>
<ToolMarkdownBlock
content={outputText}
onLinkClick={onLinkClick}
collapsible
/>
</ToolSection>
) : null}
</div>
);
}
function formatAnswer(
answer: string | string[] | null | undefined
): string | null {
if (Array.isArray(answer)) {
return answer.filter((value) => value.trim()).join(", ") || null;
}
return typeof answer === "string" && answer.trim() ? answer.trim() : null;
}
function stringValue(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
@@ -0,0 +1,33 @@
import type { JSX } from "react";
import { AgentTerminalBlock } from "./terminal/AgentTerminalBlock";
import type { AgentToolRendererProps } from "./agentToolContentShared";
import {
getCommandRenderData,
getToolCallFailureText
} from "./render-data/agentToolRenderData";
export function AgentBashContent({
call
}: AgentToolRendererProps): JSX.Element {
"use memo";
const commandData = getCommandRenderData(call);
const structuredFailureText =
commandData.status === "failed" &&
!commandData.stdout &&
!commandData.stderr
? getToolCallFailureText(call)
: null;
return (
<div className="workspace-agents-status-panel__detail-tool-body workspace-agents-status-panel__detail-tool-body--plain">
<AgentTerminalBlock
command={commandData.command}
stdout={commandData.stdout}
stderr={commandData.stderr || structuredFailureText || ""}
exitCode={commandData.exitCode}
durationMs={commandData.durationMs}
status={commandData.status}
/>
</div>
);
}
@@ -0,0 +1,101 @@
import type { JSX } from "react";
import { translate } from "../../../../i18n/index";
import { AgentCodeBlock } from "./code/AgentCodeBlock";
import { AgentMonacoDiffViewer } from "./file-diff/AgentMonacoDiffViewer";
import { AgentUnifiedPatchViewer } from "./file-diff/AgentUnifiedPatchViewer";
import {
stringValue,
ToolMarkdownBlock,
ToolSection,
type AgentToolRendererProps
} from "./agentToolContentShared";
import {
getFileChangeRenderData,
getToolCallFailureText
} from "./render-data/agentToolRenderData";
export function AgentEditContent({
call,
onLinkClick
}: AgentToolRendererProps): JSX.Element | null {
"use memo";
const files = getFileChangeRenderData(call);
const [file] = files;
const path =
file?.path ??
stringValue(call.input?.path) ??
stringValue(call.input?.file_path) ??
stringValue(call.input?.filePath) ??
null;
const patchFiles = files.filter(
(candidate) => candidate.unifiedDiff !== null
);
const diffFiles = files.filter(
(candidate) =>
candidate.unifiedDiff === null &&
candidate.oldString !== null &&
candidate.newString !== null
);
const contentFiles = files.filter(
(candidate) =>
candidate.unifiedDiff === null &&
!(candidate.oldString !== null && candidate.newString !== null) &&
candidate.content !== null
);
const failureText = getToolCallFailureText(call);
const hasRenderableContent =
Boolean(failureText) ||
Boolean(path && files.length === 0) ||
patchFiles.length > 0 ||
diffFiles.length > 0 ||
contentFiles.length > 0;
if (!hasRenderableContent) {
return null;
}
return (
<div className="workspace-agents-status-panel__detail-tool-body workspace-agents-status-panel__detail-tool-body--flat">
{failureText ? (
<ToolSection title={translate("agentHost.agentTool.details.error")}>
<ToolMarkdownBlock
content={failureText}
onLinkClick={onLinkClick}
collapsible
/>
</ToolSection>
) : null}
{path && files.length === 0 ? (
<ToolMarkdownBlock content={path} onLinkClick={onLinkClick} />
) : null}
{patchFiles.map((candidate) => (
<AgentUnifiedPatchViewer
key={`patch:${candidate.path}`}
path={candidate.path}
diffText={candidate.unifiedDiff!}
compact
flat
/>
))}
{diffFiles.map((candidate) => (
<AgentMonacoDiffViewer
key={`diff:${candidate.path}`}
path={candidate.path}
oldValue={candidate.oldString!}
newValue={candidate.newString!}
flat
/>
))}
{contentFiles.map((candidate) => (
<AgentCodeBlock
key={`content:${candidate.path}`}
path={candidate.path}
content={candidate.content!}
language={candidate.language}
showHeader={false}
flat
/>
))}
</div>
);
}
@@ -0,0 +1,88 @@
import type { JSX } from "react";
import type { AgentToolCallVM } from "../../contracts/agentToolCallVM";
import { AgentApprovalContent } from "./AgentApprovalContent";
import { AgentAskUserQuestionContent } from "./AgentAskUserQuestionContent";
import { AgentBashContent } from "./AgentBashContent";
import { AgentEditContent } from "./AgentEditContent";
import { AgentImageGenerationContent } from "./AgentImageGenerationContent";
import { AgentMcpToolContent } from "./AgentMcpToolContent";
import { AgentPlanModeContent } from "./AgentPlanModeContent";
import { AgentReadContent } from "./AgentReadContent";
import { AgentSearchContent } from "./AgentSearchContent";
import { AgentSkillContent } from "./AgentSkillContent";
import { AgentTaskContent } from "./AgentTaskContent";
import { AgentTodoWriteContent } from "./AgentTodoWriteContent";
import { AgentToolSearchContent } from "./AgentToolSearchContent";
import { AgentDefaultToolContent } from "./agentToolContentShared";
import { AgentWebFetchContent } from "./AgentWebFetchContent";
import { AgentWebSearchContent } from "./AgentWebSearchContent";
import { AgentWriteContent } from "./AgentWriteContent";
export function AgentExpandedToolContent({
call,
onLinkClick
}: {
call: AgentToolCallVM;
onLinkClick?: (href: string) => void;
}): JSX.Element | null {
"use memo";
const props = { call, onLinkClick };
let content: JSX.Element | null;
switch (call.rendererKind) {
case "approval":
content = <AgentApprovalContent {...props} />;
break;
case "plan-enter":
case "plan-exit":
content = <AgentPlanModeContent {...props} />;
break;
case "ask-user":
content = <AgentAskUserQuestionContent {...props} />;
break;
case "task":
content = <AgentTaskContent {...props} />;
break;
case "read":
content = <AgentReadContent {...props} />;
break;
case "write":
content = <AgentWriteContent {...props} />;
break;
case "edit":
content = <AgentEditContent {...props} />;
break;
case "bash":
content = <AgentBashContent {...props} />;
break;
case "search":
content = <AgentSearchContent {...props} />;
break;
case "web-search":
content = <AgentWebSearchContent {...props} />;
break;
case "web-fetch":
content = <AgentWebFetchContent {...props} />;
break;
case "image-generation":
content = <AgentImageGenerationContent {...props} />;
break;
case "todo-write":
content = <AgentTodoWriteContent {...props} />;
break;
case "tool-search":
content = <AgentToolSearchContent {...props} />;
break;
case "skill":
content = <AgentSkillContent {...props} />;
break;
case "mcp":
content = <AgentMcpToolContent {...props} />;
break;
default:
content = <AgentDefaultToolContent {...props} />;
}
if (!content) {
return null;
}
return content;
}
@@ -0,0 +1,28 @@
import type { JSX } from "react";
import { translate } from "../../../../i18n/index";
import type { AgentToolRendererProps } from "./agentToolContentShared";
import { ToolMarkdownBlock, ToolSection } from "./agentToolContentShared";
import { getImageGenerationRenderData } from "./render-data/agentToolRenderData";
export function AgentImageGenerationContent({
call,
onLinkClick
}: AgentToolRendererProps): JSX.Element | null {
"use memo";
const image = getImageGenerationRenderData(call);
if (!image.prompt) {
return null;
}
return (
<div className="workspace-agents-status-panel__detail-tool-body">
<ToolSection title={translate("agentHost.agentTool.details.input")}>
<ToolMarkdownBlock
content={image.prompt}
onLinkClick={onLinkClick}
collapsible
/>
</ToolSection>
</div>
);
}
@@ -0,0 +1,78 @@
import type { JSX } from "react";
import { translate } from "../../../../i18n/index";
import {
dedupeToolSectionContent,
RawPayloadSection,
ToolMarkdownBlock,
ToolSection,
type AgentToolRendererProps
} from "./agentToolContentShared";
import { renderRegisteredMcp } from "./mcp-renderers/agentMcpRendererRegistry";
import { normalizeMcpPayload } from "./mcp-renderers/agentMcpShared";
export function AgentMcpToolContent({
call,
onLinkClick
}: AgentToolRendererProps): JSX.Element {
"use memo";
const payload = normalizeMcpPayload(call);
const specialized = renderRegisteredMcp(payload);
const visibleText = dedupeToolSectionContent(
payload.text,
payload.inputSummary
);
const errorText = dedupeToolSectionContent(payload.errorText);
return (
<div className="workspace-agents-status-panel__detail-tool-body">
{payload.server || payload.tool ? (
<ToolSection title={translate("agentHost.agentTool.details.mcp")}>
<ToolMarkdownBlock
content={[
payload.server
? `${translate("agentHost.agentTool.details.mcpServer")}: ${payload.server}`
: null,
payload.tool
? `${translate("agentHost.agentTool.details.mcpTool")}: ${payload.tool}`
: null
]
.filter((value): value is string => Boolean(value))
.join("\n\n")}
onLinkClick={onLinkClick}
/>
</ToolSection>
) : null}
{payload.inputSummary ? (
<ToolSection title={translate("agentHost.agentTool.details.input")}>
<ToolMarkdownBlock
content={payload.inputSummary}
onLinkClick={onLinkClick}
/>
</ToolSection>
) : null}
{specialized ? (
<ToolSection title={translate("agentHost.agentTool.details.output")}>
{specialized}
</ToolSection>
) : visibleText ? (
<ToolSection title={translate("agentHost.agentTool.details.output")}>
<ToolMarkdownBlock
content={visibleText}
onLinkClick={onLinkClick}
collapsible
/>
</ToolSection>
) : null}
{errorText ? (
<ToolSection title={translate("agentHost.agentTool.details.error")}>
<ToolMarkdownBlock
content={errorText}
onLinkClick={onLinkClick}
collapsible
/>
</ToolSection>
) : null}
<RawPayloadSection payload={call.payload ?? payload.structured} />
</div>
);
}
@@ -0,0 +1,55 @@
import type { JSX } from "react";
interface AgentPathTailLabelProps {
path?: string | null;
fallback: string;
className?: string;
}
export function AgentPathTailLabel({
path,
fallback,
className
}: AgentPathTailLabelProps): JSX.Element {
"use memo";
const label = path?.trim() || fallback;
const parts = splitPathTail(label);
if (!parts) {
return (
<span
className={`agent-path-tail-label ${className ?? ""}`}
title={path ?? undefined}
>
<span className="agent-path-tail-label__file">{label}</span>
</span>
);
}
return (
<span
className={`agent-path-tail-label ${className ?? ""}`}
title={path ?? undefined}
>
<span className="agent-path-tail-label__directory">
{parts.directory}
</span>
<span className="agent-path-tail-label__file">{parts.fileName}</span>
</span>
);
}
function splitPathTail(
value: string
): { directory: string; fileName: string } | null {
const lastForwardSlash = value.lastIndexOf("/");
const lastBackwardSlash = value.lastIndexOf("\\");
const lastSeparator = Math.max(lastForwardSlash, lastBackwardSlash);
if (lastSeparator <= 0 || lastSeparator === value.length - 1) {
return null;
}
return {
directory: value.slice(0, lastSeparator + 1),
fileName: value.slice(lastSeparator + 1)
};
}
@@ -0,0 +1,40 @@
import type { JSX } from "react";
import {
ToolMarkdownBlock,
type AgentToolRendererProps
} from "./agentToolContentShared";
import { getPlanModeRenderData } from "./render-data/agentToolRenderData";
import { AgentPlanCard } from "../AgentPlanCard";
export function AgentPlanModeContent({
call,
onLinkClick
}: AgentToolRendererProps): JSX.Element | null {
"use memo";
const planMode = getPlanModeRenderData(call);
if (call.rendererKind === "plan-enter") {
if (!planMode.enterText) {
return null;
}
return (
<div className="rounded-[8px] bg-[var(--transparency-block)] px-3 py-2 text-[11px] text-[var(--text-secondary)]">
<ToolMarkdownBlock
content={planMode.enterText}
onLinkClick={onLinkClick}
/>
</div>
);
}
if (!planMode.plan) {
return null;
}
return (
<AgentPlanCard
title={planMode.fileName ?? undefined}
copyText={planMode.plan}
>
<ToolMarkdownBlock content={planMode.plan} onLinkClick={onLinkClick} />
</AgentPlanCard>
);
}
@@ -0,0 +1,100 @@
import type { JSX } from "react";
import { AgentCodeBlock } from "./code/AgentCodeBlock";
import {
objectValue,
stringValue,
type AgentToolRendererProps
} from "./agentToolContentShared";
import { AgentPathTailLabel } from "./AgentPathTailLabel";
export function AgentReadContent({
call
}: AgentToolRendererProps): JSX.Element {
"use memo";
const path =
stringValue(call.input?.path) ??
stringValue(call.input?.file_path) ??
stringValue(call.input?.filePath) ??
stringValue(call.input?.file) ??
null;
const file = objectValue(call.output?.file);
const outputText =
stringValue(call.output?.text) ??
stringValue(call.output?.stdout) ??
(!file ? call.summary.trim() || null : null);
const fileLineRange = fileRange(file);
const fileTotalLines = numericValue(file?.totalLines);
return (
<div className="workspace-agents-status-panel__detail-tool-body workspace-agents-status-panel__detail-tool-body--plain">
{outputText ? (
<AgentCodeBlock
path={path}
content={outputText}
language={languageForPath(path)}
/>
) : path || fileLineRange || fileTotalLines !== null ? (
<div className="rounded-[8px] border border-[var(--line-2)] bg-[var(--background-panel)] px-3 py-2">
{path ? (
<AgentPathTailLabel
path={path}
fallback="File"
className="font-[var(--tsh-font-mono)] text-[11px] text-[var(--text-secondary)]"
/>
) : null}
{fileLineRange || fileTotalLines !== null ? (
<div className="mt-1 text-[11px] text-[var(--text-secondary)]">
{[
fileLineRange,
fileTotalLines !== null ? `${fileTotalLines} lines` : null
]
.filter((value): value is string => Boolean(value))
.join(" · ")}
</div>
) : null}
</div>
) : null}
</div>
);
}
export function fileRange(file: Record<string, unknown> | null): string | null {
const startLine = numericValue(file?.startLine);
const numLines = numericValue(file?.numLines);
if (startLine === null || numLines === null || numLines <= 0) {
return null;
}
const endLine = startLine + numLines - 1;
return startLine === endLine ? `L${startLine}` : `L${startLine}-${endLine}`;
}
function numericValue(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function languageForPath(path: string | null): string | null {
const extension = path?.split(".").pop()?.toLowerCase();
switch (extension) {
case "ts":
case "tsx":
return "typescript";
case "js":
case "jsx":
return "javascript";
case "go":
return "go";
case "md":
return "markdown";
case "json":
return "json";
default:
return extension || null;
}
}
@@ -0,0 +1,129 @@
import type { JSX } from "react";
import { translate } from "../../../../i18n/index";
import {
dedupeToolSectionContent,
ToolMarkdownBlock,
ToolSection,
type AgentToolRendererProps
} from "./agentToolContentShared";
import { getSearchRenderData } from "./render-data/agentToolRenderData";
export function AgentSearchContent({
call,
onLinkClick
}: AgentToolRendererProps): JSX.Element {
"use memo";
const search = getSearchRenderData(call);
const queryText = search.scope
? `${search.query ?? ""}\n\n${translate("agentHost.agentTool.details.scope")}: ${search.scope}`.trim()
: search.query;
const resultFiles = withStableOccurrenceKeys(search.files, "file");
const visibleOutput = dedupeToolSectionContent(search.output, queryText);
const outputLines = withStableOccurrenceKeys(
visibleOutput.split("\n").filter(Boolean),
"line"
);
return (
<div className="workspace-agents-status-panel__detail-tool-body">
{queryText ? (
<ToolSection title={translate("agentHost.agentTool.details.query")}>
<ToolMarkdownBlock content={queryText} onLinkClick={onLinkClick} />
</ToolSection>
) : null}
{(search.mode === "files_with_matches" || search.mode === "list_files") &&
search.files.length > 0 ? (
<ToolSection title={translate("agentHost.agentTool.details.results")}>
<div className="workspace-agents-status-panel__detail-tool-result-list overflow-hidden rounded-[8px] border border-[var(--line-2)] bg-[var(--transparency-block)]">
{resultFiles.map(({ key, value: file, isFirst }) => (
<div
key={key}
className={`px-3 py-2 font-[var(--tsh-font-mono)] text-[11px] text-[var(--text-primary)] ${
isFirst ? "" : "border-t border-[var(--line-2)]"
}`}
>
{file}
</div>
))}
</div>
</ToolSection>
) : null}
{search.mode === "content" && visibleOutput ? (
<ToolSection title={translate("agentHost.agentTool.details.output")}>
<pre className="max-h-[320px] overflow-auto rounded-[8px] border border-[var(--line-2)] bg-[var(--transparency-block)] px-3 py-2 text-[11px] leading-5 text-[var(--text-primary)]">
{outputLines.map(({ key, value: line }) => (
<div
key={key}
className={
line.includes(":")
? "text-[var(--text-primary)]"
: "text-[var(--text-tertiary)]"
}
>
{line}
</div>
))}
</pre>
</ToolSection>
) : null}
{search.mode === "count" ? (
<ToolSection title={translate("agentHost.agentTool.details.results")}>
<div className="inline-flex rounded-full border border-[var(--line-2)] bg-[var(--transparency-block)] px-2.5 py-1 text-[11px] text-[var(--text-tertiary)]">
{search.output || "0"}
</div>
</ToolSection>
) : null}
{(search.mode === "files_with_matches" ||
search.mode === "list_files" ||
search.mode === "count") &&
search.files.length === 0 &&
!visibleOutput &&
!search.error ? (
<ToolSection title={translate("agentHost.agentTool.details.results")}>
<div className="text-[11px] italic text-[var(--text-tertiary)]">
{translate("agentHost.agentTool.details.noMatches")}
</div>
</ToolSection>
) : null}
{search.mode === "unknown" && visibleOutput ? (
<ToolSection title={translate("agentHost.agentTool.details.output")}>
<ToolMarkdownBlock
content={visibleOutput}
onLinkClick={onLinkClick}
collapsible
/>
</ToolSection>
) : null}
{search.error ? (
<ToolSection title={translate("agentHost.agentTool.details.error")}>
<ToolMarkdownBlock
content={search.error}
onLinkClick={onLinkClick}
collapsible
/>
</ToolSection>
) : null}
</div>
);
}
function withStableOccurrenceKeys(
values: readonly string[],
prefix: string
): Array<{ key: string; value: string; isFirst: boolean }> {
const seen = new Map<string, number>();
const keyedValues: Array<{ key: string; value: string; isFirst: boolean }> =
[];
let isFirst = true;
for (const value of values) {
const occurrence = seen.get(value) ?? 0;
seen.set(value, occurrence + 1);
keyedValues.push({
key: `${prefix}:${value}:${occurrence}`,
value,
isFirst
});
isFirst = false;
}
return keyedValues;
}
@@ -0,0 +1,40 @@
import type { JSX } from "react";
import { translate } from "../../../../i18n/index";
import {
ToolSection,
type AgentToolRendererProps
} from "./agentToolContentShared";
import { ToolMarkdownBlock } from "./agentToolContentShared";
import { getSkillRenderData } from "./render-data/agentToolRenderData";
export function AgentSkillContent({
call,
onLinkClick
}: AgentToolRendererProps): JSX.Element | null {
"use memo";
const skill = getSkillRenderData(call);
if (!skill.skill && !skill.statusText) {
return null;
}
return (
<div className="workspace-agents-status-panel__detail-tool-body">
{skill.skill ? (
<ToolSection title={translate("agentHost.agentTool.details.skill")}>
<ToolMarkdownBlock
content={
skill.args ? `${skill.skill}\n\n${skill.args}` : skill.skill
}
onLinkClick={onLinkClick}
/>
</ToolSection>
) : null}
{skill.statusText ? (
<div className="text-[10px] text-[var(--text-tertiary)]">
{skill.statusText}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,95 @@
import type { JSX } from "react";
import { translate } from "../../../../i18n/index";
import {
ToolMarkdownBlock,
ToolSection,
type AgentToolRendererProps
} from "./agentToolContentShared";
import { AgentTaskStepList } from "../AgentTaskStepList";
import { getTaskRenderData } from "./render-data/agentToolRenderData";
export function AgentTaskContent({
call,
onLinkClick
}: AgentToolRendererProps): JSX.Element {
"use memo";
const task = getTaskRenderData(call);
const failureMarkdown =
task.errorMarkdown ??
(isFailedTaskStatus(call.statusKind, task.status, call.status)
? translate("agentHost.agentTool.details.missingFailureDetails")
: null);
return (
<div className="workspace-agents-status-panel__detail-tool-body">
<div className="workspace-agents-status-panel__detail-tool-answer">
<strong>{task.title}</strong>
{task.status ? (
<span className="workspace-agents-status-panel__detail-tool-answer--muted">
{" · "}
{task.status}
</span>
) : null}
{task.durationText ? (
<span className="workspace-agents-status-panel__detail-tool-answer--muted">
{" · "}
{task.durationText}
</span>
) : null}
</div>
{task.status === "running" && task.latestStepSummary ? (
<ToolSection title={translate("agentHost.agentTool.details.summary")}>
<div className="workspace-agents-status-panel__detail-tool-answer workspace-agents-status-panel__detail-tool-answer--muted">
{task.latestStepSummary}
</div>
</ToolSection>
) : null}
{task.prompt ? (
<ToolSection title={translate("agentHost.agentTool.details.prompt")}>
<ToolMarkdownBlock content={task.prompt} onLinkClick={onLinkClick} />
</ToolSection>
) : null}
{task.childSessionId ? (
<ToolSection
title={translate("agentHost.agentTool.details.delegateSession")}
>
<div className="workspace-agents-status-panel__detail-tool-answer">
{task.childSessionId}
</div>
</ToolSection>
) : null}
{task.steps.length > 0 ? (
<ToolSection title={translate("agentHost.agentTool.details.steps")}>
<AgentTaskStepList steps={task.steps} onLinkClick={onLinkClick} />
</ToolSection>
) : null}
{task.resultMarkdown ? (
<ToolSection title={translate("agentHost.agentTool.details.output")}>
<ToolMarkdownBlock
content={task.resultMarkdown}
onLinkClick={onLinkClick}
collapsible
/>
</ToolSection>
) : null}
{failureMarkdown ? (
<ToolSection title={translate("agentHost.agentTool.details.error")}>
<ToolMarkdownBlock
content={failureMarkdown}
onLinkClick={onLinkClick}
collapsible
/>
</ToolSection>
) : null}
</div>
);
}
function isFailedTaskStatus(
...values: Array<string | null | undefined>
): boolean {
return values.some((value) => {
const normalized = (value ?? "").trim().toLowerCase();
return normalized === "failed" || normalized === "error";
});
}
@@ -0,0 +1,81 @@
import type { JSX } from "react";
import { CheckCircle2, Circle } from "lucide-react";
import { AtomIcon } from "../../../../app/renderer/components/icons/AtomIcon";
import { translate } from "../../../../i18n/index";
import {
ToolSection,
type AgentToolRendererProps
} from "./agentToolContentShared";
import { getTodoRenderData } from "./render-data/agentToolRenderData";
export function AgentTodoWriteContent({
call
}: AgentToolRendererProps): JSX.Element | null {
"use memo";
const todos = getTodoRenderData(call);
if (todos.length === 0) {
return null;
}
return (
<div className="workspace-agents-status-panel__detail-tool-body">
<ToolSection title={translate("agentHost.agentTool.details.todos")}>
<div className="space-y-1">
{todos.map((todo) => (
<div
key={`${todo.content}:${todo.status ?? "pending"}`}
className="flex items-start gap-2 rounded-[8px] px-1 py-0.5 text-[11px]"
>
<div className="mt-[1px] shrink-0">
{iconForStatus(todo.status)}
</div>
<span className={contentClassName(todo.status)}>
{todo.content}
</span>
</div>
))}
</div>
</ToolSection>
</div>
);
}
function iconForStatus(status: string | null): JSX.Element {
switch (status) {
case "completed":
return (
<CheckCircle2
size={14}
className="text-[var(--state-success)]"
aria-hidden="true"
/>
);
case "in_progress":
return (
<AtomIcon
size={14}
active
className="text-[var(--tutti-purple)]"
aria-hidden="true"
/>
);
default:
return (
<Circle
size={14}
className="text-[var(--text-tertiary)]"
aria-hidden="true"
/>
);
}
}
function contentClassName(status: string | null): string {
switch (status) {
case "completed":
return "text-[var(--text-tertiary)] line-through";
case "in_progress":
return "text-[var(--text-primary)]";
default:
return "text-[var(--text-secondary)]";
}
}
@@ -0,0 +1,37 @@
import type { HTMLAttributes, JSX, ReactNode } from "react";
import { CustomScrollArea } from "../../../../app/renderer/components/ui/custom-scroll-area";
import { cn } from "../../../../app/renderer/lib/utils";
interface AgentToolScrollAreaProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
viewportClassName?: string;
maxHeightClassName?: string;
}
export function AgentToolScrollArea({
children,
className,
viewportClassName,
maxHeightClassName = "max-h-[240px]",
...props
}: AgentToolScrollAreaProps): JSX.Element {
"use memo";
return (
<CustomScrollArea
className={cn(
"agent-tool-scroll-area group/agent-tool-scroll",
className
)}
viewportClassName={cn(
"workspace-agents-status-panel__detail-scroll-region",
maxHeightClassName,
viewportClassName
)}
scrollbarClassName="agent-tool-scroll-area__scrollbar"
scrollbarThumbClassName="workspace-agents-status-panel__scrollbar-thumb"
{...props}
>
{children}
</CustomScrollArea>
);
}
@@ -0,0 +1,59 @@
import type { JSX } from "react";
import { translate } from "../../../../i18n/index";
import {
ToolMarkdownBlock,
ToolSection,
type AgentToolRendererProps
} from "./agentToolContentShared";
import { getToolSearchRenderData } from "./render-data/agentToolRenderData";
export function AgentToolSearchContent({
call
}: AgentToolRendererProps): JSX.Element | null {
"use memo";
const toolSearch = getToolSearchRenderData(call);
if (!toolSearch.query && toolSearch.matches.length === 0) {
return null;
}
return (
<div className="workspace-agents-status-panel__detail-tool-body">
{toolSearch.displayQuery ? (
<ToolSection title={translate("agentHost.agentTool.details.query")}>
<ToolMarkdownBlock
content={`${toolSearch.displayQuery}\n\n${toolSearch.mode}`}
/>
</ToolSection>
) : null}
{toolSearch.matches.length > 0 ? (
<ToolSection title={translate("agentHost.agentTool.details.results")}>
<div className="workspace-agents-status-panel__detail-tool-result-list overflow-hidden rounded-[8px] border border-[var(--line-2)] bg-[var(--transparency-block)]">
{toolSearch.matches.map((match, index) => (
<div
key={match}
className={`px-3 py-2 font-[var(--tsh-font-mono)] text-[11px] text-[var(--text-primary)] ${
index > 0 ? "border-t border-[var(--line-2)]" : ""
}`}
>
{match}
</div>
))}
</div>
</ToolSection>
) : null}
{toolSearch.matches.length === 0 ? (
<div className="text-[11px] italic text-[var(--text-tertiary)]">
{translate("agentHost.agentTool.details.noMatchingTools")}
</div>
) : null}
{typeof toolSearch.totalDeferredTools === "number" ? (
<div className="text-[10px] text-[var(--text-tertiary)]">
{translate("agentHost.agentTool.details.loadedAvailable", {
loaded: toolSearch.matches.length,
available: toolSearch.totalDeferredTools
})}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,75 @@
import type { JSX } from "react";
import { translate } from "../../../../i18n/index";
import {
dedupeToolSectionContent,
ToolMarkdownBlock,
ToolSection,
type AgentToolRendererProps
} from "./agentToolContentShared";
import {
getToolFallbackText,
getWebFetchRenderData
} from "./render-data/agentToolRenderData";
export function AgentWebFetchContent({
call,
onLinkClick
}: AgentToolRendererProps): JSX.Element | null {
"use memo";
const web = getWebFetchRenderData(call);
const urlText =
web.url && web.domain && web.domain !== web.url
? `${web.domain}\n\n${web.url}`
: web.url;
const visibleContent = dedupeToolSectionContent(
web.visibleContent,
web.url,
web.domain,
urlText
);
const fallbackText = getToolFallbackText(call);
const errorText = dedupeToolSectionContent(
fallbackText.error,
urlText,
visibleContent
);
if (!web.url && !visibleContent && !errorText) {
return null;
}
return (
<div className="workspace-agents-status-panel__detail-tool-body">
{web.url ? (
<ToolSection title={translate("agentHost.agentTool.details.url")}>
<ToolMarkdownBlock
content={urlText ?? ""}
onLinkClick={onLinkClick}
/>
</ToolSection>
) : null}
{visibleContent ? (
<ToolSection title={translate("agentHost.agentTool.details.content")}>
<ToolMarkdownBlock
content={visibleContent}
onLinkClick={onLinkClick}
collapsible
/>
</ToolSection>
) : null}
{web.isTruncated ? (
<div className="text-[10px] italic text-[var(--text-tertiary)]">
{translate("agentHost.agentTool.details.contentTruncated")}
</div>
) : null}
{errorText ? (
<ToolSection title={translate("agentHost.agentTool.details.error")}>
<ToolMarkdownBlock
content={errorText}
onLinkClick={onLinkClick}
collapsible
/>
</ToolSection>
) : null}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More