源码快照 @ 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
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:
@@ -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
|
||||
});
|
||||
}
|
||||
+34
@@ -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();
|
||||
});
|
||||
});
|
||||
+32
@@ -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("-");
|
||||
}
|
||||
+40
@@ -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`;
|
||||
}
|
||||
+101
@@ -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>
|
||||
);
|
||||
});
|
||||
+90
@@ -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>
|
||||
);
|
||||
}
|
||||
+626
@@ -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>
|
||||
);
|
||||
}
|
||||
+218
@@ -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>
|
||||
);
|
||||
}
|
||||
+128
@@ -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");
|
||||
});
|
||||
});
|
||||
+67
@@ -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" })
|
||||
);
|
||||
});
|
||||
});
|
||||
+95
@@ -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>
|
||||
);
|
||||
}
|
||||
+561
@@ -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();
|
||||
});
|
||||
});
|
||||
+53
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+1680
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
+1231
File diff suppressed because it is too large
Load Diff
@@ -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]
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
});
|
||||
}
|
||||
+117
@@ -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;
|
||||
}
|
||||
}
|
||||
+63
@@ -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>
|
||||
);
|
||||
}
|
||||
+73
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+72
@@ -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;
|
||||
+105
@@ -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 |",
|
||||
""
|
||||
].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]);
|
||||
}
|
||||
+562
@@ -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
|
||||
};
|
||||
}
|
||||
+331
@@ -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;
|
||||
}
|
||||
+153
@@ -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();
|
||||
}
|
||||
+107
@@ -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;
|
||||
}
|
||||
+33
@@ -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>
|
||||
);
|
||||
}
|
||||
+101
@@ -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>
|
||||
);
|
||||
}
|
||||
+1518
File diff suppressed because it is too large
Load Diff
+88
@@ -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;
|
||||
}
|
||||
+28
@@ -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>
|
||||
);
|
||||
}
|
||||
+78
@@ -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>
|
||||
);
|
||||
}
|
||||
+55
@@ -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)
|
||||
};
|
||||
}
|
||||
+40
@@ -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>
|
||||
);
|
||||
}
|
||||
+100
@@ -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;
|
||||
}
|
||||
}
|
||||
+129
@@ -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;
|
||||
}
|
||||
+40
@@ -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>
|
||||
);
|
||||
}
|
||||
+95
@@ -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";
|
||||
});
|
||||
}
|
||||
+81
@@ -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)]";
|
||||
}
|
||||
}
|
||||
+37
@@ -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>
|
||||
);
|
||||
}
|
||||
+59
@@ -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>
|
||||
);
|
||||
}
|
||||
+75
@@ -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
Reference in New Issue
Block a user