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
268 lines
8.1 KiB
TypeScript
268 lines
8.1 KiB
TypeScript
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
|
|
});
|
|
}
|