mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* feat(views): transcript reading hierarchy — presenter, expand modes, log-scale markdown The transcript rendered all five event kinds as identical truncated one-liners: agent replies and errors were clipped to half a sentence and only readable through an 11px bordered scroll box, indistinguishable in weight from dozens of tool rows. - trace-event-presenter.ts: pure presentation rules — kind, verbatim tool labels, newline-free one-line summaries, shell-wrapper stripping for command summaries, and per-kind default expansion. - Smart reading hierarchy: agent text renders in place through RichContent (compact density + transcript-prose log scale: headings demoted to body size, 12.5px/11px two-step type ramp) and errors read unboxed; thinking and tool rows stay folded to one line. - Expand mode menu replaces the expand-visible toggle: a persisted three-way preference (smart / expand all / collapse all) with per-item descriptions; row-level toggles override it until the mode changes. transcript-view-store migrates the legacy defaultExpanded boolean. - Tool params/output expand into a quiet borderless surface; long content fades behind "Show all" instead of a nested scrollbar. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(views): transcript header — status-first identity row, facts as typography, list toolbar The header mixed five different natures in one visual container: status pill, attribution badge, nine equal-weight metadata chips (including truncated agent-description prose and a hostname), and the list controls — everything a rounded bordered capsule, so information and controls were indistinguishable and wrapped into a ragged 2-3 lines. Restructure into three fixed-purpose rows: - Identity: status pill anchors the left edge (the fact every viewer opens the dialog for), then the agent through its existing avatar component (hover card for details; fixes the empty-name case via agentInfo fallback) and a new borderless `inline` AttributionBadge variant. Close stays at the right. - Facts: one dot-separated plain-text line — provider, runtime mode (hostname in hover title), duration, counts, timestamp; the workdir path collapses into a copy icon with the path as tooltip. The agent-description chip is gone (it lives in the avatar hover card). - List toolbar: expand mode / sort / filter / copy move to their own row attached to the list they operate. Only controls keep borders; only status keeps color; entities render through their identity components; facts are typography. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(views): transcript header — two-tier by necessity (ⓘ popover), trigger source, full status machine Following PR #5747's design lesson: split header metadata by whether a viewer needs it BEFORE reading. The flat facts line put diagnostics (provider, runtime hostname, mode, workdir, timestamps) on the always-visible surface with equal weight. - Identity row (tier 1): status · agent · trigger source · attribution. Trigger source ("Initial run" / "From a comment" / "Retry" / ...) answers "why does this run exist" — restored from #5747, was dropped. - Status badge now covers the full state machine (queued / dispatched / cancelled / waiting), not just running/completed/failed. - ⓘ Run-details popover (tier 2): runtime, provider, mode, workdir (copyable), created/started/completed — off the default surface. - Toolbar left carries duration + event/tool counts (a read-before-you- read summary), so the control row balances instead of stranding the controls at the right with empty left space. Persisted preferences (sort, filter selection, preserve-filters, expand density) are untouched — same controls, same behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(views): transcript summary/controls — JSON preview, unify sort button, attribution to ⓘ - Tool-result summary took the first non-empty line, so pretty-printed JSON previewed as a lone "[" or "{". Collapse whitespace instead so it reads "[ { "id": ... } ]". - Sort was a segmented tab strip, reading as a different control family. Make it a single two-state toggle button on the shared toolbar chassis (shows current direction, flips on click) — every toolbar control now shares one chassis with a type-appropriate affordance (toggle / menu / action), not one forced shape. - Attribution left the identity row. The accountable human is audit metadata, not read-time context, and "on behalf of" misread the direction; the trigger *mechanism* stays on the identity row, the *person* moves to the ⓘ popover as "Triggered by". - Drop the tool-call count from the toolbar: redundant with the event count and informs no reading decision. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(views): transcript toolbar uses the shared Button component + labels fix Toolbar controls were hand-rolled <button>s, so they never got the project's built-in aria-expanded active state — opening a menu left the trigger looking idle. Route every control through the shared Button: - Density / filter / ⓘ / close → Button variant="ghost" via the trigger render prop, so an open menu shows the muted active background for free. - Active filter → Button variant="brand" (the design-system ON state), replacing the ad-hoc blue classes. - Sort → ghost Button on the same chassis (was a segmented tab strip). - Copy → ghost Button. Wording: - Sort reads "Oldest first" / "Newest first" (was the ambiguous "chronological / time order"). - Expand-mode "Smart" → "Focus" — names the result, not a hollow adjective. Start/created/completed timestamps remain in the ⓘ Run-details popover. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(views): filters always persist, drop "on behalf of" wording, surface run time Three follow-ups from review: - Remove the "Preserve filters" toggle. Persisting the filter selection is the only sensible behavior (a facet a run lacks already no-ops), so it should not be a user-facing switch. Filters now always persist; drop preserveFilters + the session-vs-persisted branching from the store and dialog. - Inline attribution showed "on behalf of <name>", which read backwards and doubled up under the ⓘ "Triggered by" label. Render just the avatar + name; the source stays in the tooltip. - Surface the run time (started ?? created) on the toolbar left next to duration and event count — "when did this happen" is read-before-you- read context. Full-precision timestamps remain in the ⓘ popover. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(views): triggered-by back on the identity row, labeled toolbar facts - The accountable human returns to the identity row next to the trigger mechanism (status · agent · trigger · person), where it's visible rather than buried in the ⓘ popover. - Toolbar facts now carry explicit labels — "Created <time> · Took <duration> · N events" — instead of bare values the reader had to infer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(views): transcript identity row reads as one sentence, not three peers The row put the agent, the trigger word, and the person at near-equal weight with two same-size avatars, so it read as a jumble of names with "comment-triggered" floating between them. - The agent is the sole primary identity (avatar + medium weight). - Trigger + person collapse into one muted secondary unit set apart from the agent, ordered "<person> · <how>". - Drop the person's avatar here (new AttributionBadge `hideAvatar`) so two same-size faces don't read as two agents; the name alone, being muted, can't be confused with the agent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(views): copy-all exports full event bodies, not truncated summaries handleCopyAll built its text from traceEventSummary — the one-line, whitespace-collapsed, 200-char row preview — so every copied tool output and agent reply was truncated with "...". Add traceEventCopyText, which returns the complete body (full tool input JSON / result output / prose), redact it like the detail view, and join events with blank lines. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(views): remove orphaned tool_calls i18n keys, fix inline-variant JSDoc Review follow-ups (no behavior change): - Delete tool_calls_one/tool_calls_other from all four locales — the metadata chip that used them was removed earlier in this branch. - Correct the AttributionBadge inline-variant JSDoc: it renders the bare name (no "on behalf of" wrapper), matching the implementation and test. - en events_one grammar: "1 event" not "1 events". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(views): fold copy timestamps into full-body copy (merge #5873) Reconcile #5873's "timestamps in copied events" (merged to main) with this branch's full-body copy rewrite: traceEventCopyText now prefixes each line with the RFC 3339 timestamp when created_at is valid, on top of the complete (untruncated) body, events separated by a blank line. The two #5873 copy tests are updated to the full-body format; the orphaned formatEventForClipboard helper is removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
443 lines
13 KiB
TypeScript
443 lines
13 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { cleanup, fireEvent, screen } from "@testing-library/react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
|
import { api } from "@multica/core/api";
|
|
import type { AgentRuntime, AgentTask } from "@multica/core/types/agent";
|
|
import { useTranscriptViewStore } from "@multica/core/agents/stores";
|
|
import { renderWithI18n } from "../../test/i18n";
|
|
import { AgentTranscriptDialog } from "./agent-transcript-dialog";
|
|
import type { TimelineItem } from "./build-timeline";
|
|
|
|
const copyTextMock = vi.hoisted(() => vi.fn().mockResolvedValue(true));
|
|
|
|
vi.mock("@multica/core/api", () => ({
|
|
api: {
|
|
getAgent: vi.fn().mockResolvedValue(null),
|
|
listRuntimes: vi.fn().mockResolvedValue([]),
|
|
},
|
|
}));
|
|
|
|
vi.mock("@multica/ui/lib/clipboard", () => ({
|
|
copyText: copyTextMock,
|
|
}));
|
|
|
|
// Real react-virtuoso renders no data rows under jsdom's zero-height viewport,
|
|
// so stub it with a flat render to make rows visible to these tests.
|
|
vi.mock("react-virtuoso", () => ({
|
|
Virtuoso: ({
|
|
data,
|
|
itemContent,
|
|
computeItemKey,
|
|
}: {
|
|
data: TimelineItem[];
|
|
itemContent: (i: number, item: TimelineItem) => ReactNode;
|
|
computeItemKey: (i: number, item: TimelineItem) => number;
|
|
}) => (
|
|
<div>
|
|
{data.map((item, i) => (
|
|
<div key={computeItemKey(i, item)}>{itemContent(i, item)}</div>
|
|
))}
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock("../actor-avatar", () => ({
|
|
ActorAvatar: () => <span data-testid="actor-avatar" />,
|
|
}));
|
|
|
|
vi.mock("@multica/ui/components/ui/dialog", () => ({
|
|
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
|
|
open ? <>{children}</> : null,
|
|
DialogContent: ({ children }: { children: ReactNode }) => (
|
|
<div role="dialog">{children}</div>
|
|
),
|
|
DialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
|
|
}));
|
|
|
|
vi.mock("@multica/ui/components/ui/dropdown-menu", async () => {
|
|
const React = await import("react");
|
|
const RadioContext = React.createContext<{
|
|
value?: string;
|
|
onValueChange?: (value: string) => void;
|
|
}>({});
|
|
|
|
return {
|
|
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
|
DropdownMenuTrigger: ({
|
|
children,
|
|
...props
|
|
}: ButtonHTMLAttributes<HTMLButtonElement>) => (
|
|
<button type="button" {...props}>
|
|
{children}
|
|
</button>
|
|
),
|
|
DropdownMenuContent: ({ children }: { children: ReactNode }) => (
|
|
<div>{children}</div>
|
|
),
|
|
DropdownMenuSeparator: () => <hr />,
|
|
DropdownMenuCheckboxItem: ({
|
|
checked,
|
|
onCheckedChange,
|
|
children,
|
|
}: {
|
|
checked?: boolean;
|
|
onCheckedChange?: (checked: boolean) => void;
|
|
children: ReactNode;
|
|
}) => (
|
|
<button
|
|
type="button"
|
|
role="menuitemcheckbox"
|
|
aria-checked={checked === true}
|
|
onClick={() => onCheckedChange?.(checked !== true)}
|
|
>
|
|
{children}
|
|
</button>
|
|
),
|
|
DropdownMenuItem: ({
|
|
children,
|
|
onClick,
|
|
className: _className,
|
|
}: ButtonHTMLAttributes<HTMLButtonElement>) => (
|
|
<button type="button" onClick={onClick}>
|
|
{children}
|
|
</button>
|
|
),
|
|
DropdownMenuRadioGroup: ({
|
|
value,
|
|
onValueChange,
|
|
children,
|
|
}: {
|
|
value?: string;
|
|
onValueChange?: (value: string) => void;
|
|
children: ReactNode;
|
|
}) => (
|
|
<RadioContext.Provider value={{ value, onValueChange }}>{children}</RadioContext.Provider>
|
|
),
|
|
DropdownMenuRadioItem: ({
|
|
value,
|
|
children,
|
|
}: {
|
|
value: string;
|
|
children: ReactNode;
|
|
}) => {
|
|
const ctx = React.useContext(RadioContext);
|
|
return (
|
|
<button
|
|
type="button"
|
|
role="menuitemradio"
|
|
aria-checked={ctx.value === value}
|
|
onClick={() => ctx.onValueChange?.(value)}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
},
|
|
};
|
|
});
|
|
|
|
// The transcript body renders agent markdown through RichContent; stub it to
|
|
// keep these tests independent of the markdown pipeline.
|
|
vi.mock("../../rich-content", () => ({
|
|
RichContent: ({ content }: { content: string }) => (
|
|
<div data-testid="rich-content">{content}</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@multica/ui/components/ui/collapsible", async () => {
|
|
const React = await import("react");
|
|
const Context = React.createContext<{
|
|
open: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
}>({ open: false });
|
|
|
|
return {
|
|
Collapsible: ({
|
|
open,
|
|
onOpenChange,
|
|
children,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
children: ReactNode;
|
|
}) => (
|
|
<Context.Provider value={{ open, onOpenChange }}>{children}</Context.Provider>
|
|
),
|
|
CollapsibleTrigger: ({
|
|
disabled,
|
|
children,
|
|
className: _className,
|
|
...props
|
|
}: ButtonHTMLAttributes<HTMLButtonElement>) => {
|
|
const ctx = React.useContext(Context);
|
|
return (
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
aria-expanded={ctx.open}
|
|
onClick={() => {
|
|
if (!disabled) ctx.onOpenChange?.(!ctx.open);
|
|
}}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
},
|
|
CollapsibleContent: ({ children }: { children: ReactNode }) => {
|
|
const ctx = React.useContext(Context);
|
|
return ctx.open ? <div>{children}</div> : null;
|
|
},
|
|
};
|
|
});
|
|
|
|
const baseTask: AgentTask = {
|
|
id: "task-1",
|
|
agent_id: "",
|
|
runtime_id: "",
|
|
issue_id: "issue-1",
|
|
status: "completed",
|
|
priority: 0,
|
|
dispatched_at: null,
|
|
started_at: "2026-06-08T08:00:00Z",
|
|
completed_at: "2026-06-08T08:01:00Z",
|
|
result: null,
|
|
error: null,
|
|
created_at: "2026-06-08T08:00:00Z",
|
|
};
|
|
|
|
const liveTask: AgentTask = {
|
|
...baseTask,
|
|
runtime_id: "runtime-1",
|
|
status: "running",
|
|
completed_at: null,
|
|
};
|
|
|
|
function runtimeFor(provider: string): AgentRuntime {
|
|
return {
|
|
id: "runtime-1",
|
|
workspace_id: "workspace-1",
|
|
daemon_id: "daemon-1",
|
|
name: `${provider} runtime`,
|
|
runtime_mode: "local",
|
|
provider,
|
|
launch_header: "",
|
|
status: "online",
|
|
device_info: "",
|
|
metadata: {},
|
|
owner_id: "owner-1",
|
|
visibility: "private",
|
|
last_seen_at: null,
|
|
created_at: "2026-06-08T08:00:00Z",
|
|
updated_at: "2026-06-08T08:00:00Z",
|
|
};
|
|
}
|
|
|
|
const items: TimelineItem[] = [
|
|
{
|
|
seq: 1,
|
|
type: "text",
|
|
content: "Agent summary\nAgent hidden detail",
|
|
},
|
|
{
|
|
seq: 2,
|
|
type: "thinking",
|
|
content: "Thinking summary\nThinking hidden detail",
|
|
},
|
|
{
|
|
seq: 3,
|
|
type: "tool_use",
|
|
tool: "terminal",
|
|
input: { command: "pnpm test" },
|
|
},
|
|
];
|
|
|
|
function renderDialog(
|
|
dialogItems: TimelineItem[] = items,
|
|
options: { task?: AgentTask; isLive?: boolean } = {},
|
|
) {
|
|
return renderWithI18n(
|
|
<AgentTranscriptDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
task={options.task ?? baseTask}
|
|
items={dialogItems}
|
|
agentName="Codex"
|
|
isLive={options.isLive}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
cleanup();
|
|
copyTextMock.mockClear();
|
|
vi.mocked(api.listRuntimes).mockResolvedValue([]);
|
|
useTranscriptViewStore.setState({
|
|
sortDirection: "chronological",
|
|
selectedFilterKeys: [],
|
|
// Legacy row assertions below expect one-line summaries; smart density is
|
|
// exercised by its own tests.
|
|
density: "collapsed",
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
describe("AgentTranscriptDialog", () => {
|
|
it("explains unavailable live events for an empty Antigravity transcript", async () => {
|
|
vi.mocked(api.listRuntimes).mockResolvedValue([runtimeFor("antigravity")]);
|
|
|
|
renderDialog([], { task: liveTask, isLive: true });
|
|
|
|
expect(
|
|
await screen.findByText(
|
|
"Antigravity does not currently provide live execution events. The transcript will be available after the task completes.",
|
|
),
|
|
).toBeInTheDocument();
|
|
expect(screen.queryByText("Waiting for events...")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("keeps waiting for live events from other runtimes", async () => {
|
|
vi.mocked(api.listRuntimes).mockResolvedValue([runtimeFor("hermes")]);
|
|
|
|
renderDialog([], { task: liveTask, isLive: true });
|
|
|
|
// Runtime detail now lives in the ⓘ popover; its trigger appearing proves
|
|
// the runtime loaded. The non-antigravity live state still waits.
|
|
await screen.findByRole("button", { name: "Run details" });
|
|
expect(screen.getByText("Waiting for events...")).toBeInTheDocument();
|
|
});
|
|
|
|
it("preserves selected filters across dialog remounts unconditionally", () => {
|
|
const first = renderDialog();
|
|
|
|
fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Thinking" }));
|
|
|
|
expect(screen.queryByText("Agent summary")).not.toBeInTheDocument();
|
|
expect(screen.getByText(/Thinking summary/)).toBeInTheDocument();
|
|
expect(useTranscriptViewStore.getState().selectedFilterKeys).toEqual(["thinking"]);
|
|
|
|
first.unmount();
|
|
renderDialog();
|
|
|
|
expect(screen.queryByText("Agent summary")).not.toBeInTheDocument();
|
|
expect(screen.getByText(/Thinking summary/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("ignores stale persisted filter keys that are not available in the current transcript", () => {
|
|
useTranscriptViewStore.setState({
|
|
selectedFilterKeys: ["thinking"],
|
|
});
|
|
|
|
renderDialog([
|
|
{
|
|
seq: 1,
|
|
type: "text",
|
|
content: "Only agent summary\nOnly agent hidden detail",
|
|
},
|
|
]);
|
|
|
|
expect(screen.getByText("Only agent summary")).toBeInTheDocument();
|
|
expect(screen.queryByText("No execution data recorded.")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("switches wholesale between expand-all and collapse-all via the density menu", () => {
|
|
renderDialog();
|
|
|
|
expect(screen.queryByText(/Agent hidden detail/)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/"command": "pnpm test"/)).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole("menuitemradio", { name: /Expand all/ }));
|
|
|
|
expect(screen.getByText(/Agent hidden detail/)).toBeInTheDocument();
|
|
expect(screen.getByText(/Thinking hidden detail/)).toBeInTheDocument();
|
|
expect(screen.getByText(/"command": "pnpm test"/)).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole("menuitemradio", { name: /Collapse all/ }));
|
|
|
|
expect(screen.queryByText(/Agent hidden detail/)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/"command": "pnpm test"/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("smart density opens agent text in place and keeps process noise folded", () => {
|
|
useTranscriptViewStore.setState({ density: "smart" });
|
|
|
|
renderDialog();
|
|
|
|
// Agent body reads without a click (through RichContent), tools stay folded.
|
|
expect(screen.getByTestId("rich-content")).toHaveTextContent("Agent hidden detail");
|
|
expect(screen.queryByText(/Thinking hidden detail/)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/"command": "pnpm test"/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("row-level toggles override the density default until the mode changes", () => {
|
|
useTranscriptViewStore.setState({ density: "smart" });
|
|
|
|
renderDialog();
|
|
|
|
// Fold the default-open agent body back to one line. The `expanded`
|
|
// filter distinguishes the collapse trigger from the timeline segment,
|
|
// which also carries the "Agent" accessible name via its title.
|
|
fireEvent.click(screen.getByRole("button", { name: "Agent", expanded: true }));
|
|
expect(screen.queryByTestId("rich-content")).not.toBeInTheDocument();
|
|
expect(screen.getByText("Agent summary")).toBeInTheDocument();
|
|
|
|
// Open a default-folded thinking row.
|
|
fireEvent.click(screen.getByRole("button", { name: /Thinking summary/ }));
|
|
expect(screen.getByText(/Thinking hidden detail/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("copies RFC 3339 timestamps before event labels", () => {
|
|
renderDialog([
|
|
{
|
|
seq: 1,
|
|
type: "text",
|
|
content: "Agent summary\nAgent hidden detail",
|
|
created_at: "2026-06-08T08:00:00+08:00",
|
|
},
|
|
{
|
|
seq: 2,
|
|
type: "thinking",
|
|
content: "Thinking summary",
|
|
created_at: "2026-06-08T08:00:05.123Z",
|
|
},
|
|
]);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Copy all" }));
|
|
|
|
// Full body (not the truncated summary) with the RFC 3339 prefix, events
|
|
// separated by a blank line.
|
|
expect(copyTextMock).toHaveBeenCalledWith(
|
|
[
|
|
"[2026-06-08T00:00:00.000Z] [Agent] Agent summary\nAgent hidden detail",
|
|
"[2026-06-08T08:00:05.123Z] [Thinking] Thinking summary",
|
|
].join("\n\n"),
|
|
);
|
|
});
|
|
|
|
it("keeps older events without a valid timestamp copyable", () => {
|
|
renderDialog([
|
|
{
|
|
seq: 1,
|
|
type: "text",
|
|
content: "Missing timestamp",
|
|
},
|
|
{
|
|
seq: 2,
|
|
type: "error",
|
|
content: "Invalid timestamp",
|
|
created_at: "not-a-date",
|
|
},
|
|
]);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Copy all" }));
|
|
|
|
expect(copyTextMock).toHaveBeenCalledWith(
|
|
["[Agent] Missing timestamp", "[Error] Invalid timestamp"].join("\n\n"),
|
|
);
|
|
});
|
|
});
|