diff options
| author | Adam Malczewski <[email protected]> | 2026-06-07 18:52:13 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-07 18:52:13 +0900 |
| commit | 80f8a219c89a963c485da0f40dc428bf688fedb7 (patch) | |
| tree | 737822344118e5c1c840b8399a554a1898f07093 /src/features | |
| parent | 48c6d85c3cc5a57a729f14068e2346b17ed62088 (diff) | |
| download | dispatch-web-80f8a219c89a963c485da0f40dc428bf688fedb7.tar.gz dispatch-web-80f8a219c89a963c485da0f40dc428bf688fedb7.zip | |
Revert "feat(chat): live turn metrics — telemetry reducer + rendering"
This reverts commit 48c6d85c3cc5a57a729f14068e2346b17ed62088.
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/chat/index.ts | 2 | ||||
| -rw-r--r-- | src/features/chat/store.svelte.ts | 12 | ||||
| -rw-r--r-- | src/features/chat/store.test.ts | 46 | ||||
| -rw-r--r-- | src/features/chat/ui.test.ts | 150 | ||||
| -rw-r--r-- | src/features/chat/ui/ChatView.svelte | 93 | ||||
| -rw-r--r-- | src/features/chat/ui/TurnSummary.svelte | 75 |
6 files changed, 37 insertions, 341 deletions
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index b096cca..4f2091a 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -1,10 +1,8 @@ export type { RenderedChunk, RenderGroup, ToolBatchEntry } from "../../core/chunks"; export { groupRenderedChunks } from "../../core/chunks"; -export type { StepMetrics, TelemetryState, TurnMetrics } from "../../core/telemetry"; export type { ChatTransport, HistorySync } from "./ports"; export type { ChatStore, ChatStoreDependencies } from "./store.svelte"; export { createChatStore } from "./store.svelte"; export { default as ChatView } from "./ui/ChatView.svelte"; export { default as Composer } from "./ui/Composer.svelte"; export { default as ModelSelector } from "./ui/ModelSelector.svelte"; -export { default as TurnSummary } from "./ui/TurnSummary.svelte"; diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts index 58c165f..1d8ab17 100644 --- a/src/features/chat/store.svelte.ts +++ b/src/features/chat/store.svelte.ts @@ -13,8 +13,6 @@ import { selectChunks, selectMessages, } from "../../core/chunks"; -import type { TelemetryState } from "../../core/telemetry"; -import { foldMetricEvent, initialState as telemetryInitialState } from "../../core/telemetry"; import type { ConversationCache } from "../conversation-cache"; import type { ChatTransport, HistorySync } from "./ports"; @@ -32,8 +30,6 @@ export interface ChatStore { readonly pendingSync: boolean; readonly error: string | null; readonly model: string | undefined; - readonly telemetry: TelemetryState; - readonly currentTurnId: string | null; handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void; send(text: string): void; setModel(model: string): void; @@ -46,7 +42,6 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { let _pendingSync = $state(false); let _error = $state<string | null>(null); let _model = $state<string | undefined>(deps.model); - let telemetry = $state<TelemetryState>(telemetryInitialState()); let disposed = false; async function syncTail(): Promise<void> { @@ -81,12 +76,6 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { get model(): string | undefined { return _model; }, - get telemetry(): TelemetryState { - return telemetry; - }, - get currentTurnId(): string | null { - return transcript.currentTurnId; - }, handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void { if (msg.type === "chat.error") { @@ -100,7 +89,6 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { return; } transcript = foldEvent(transcript, msg.event); - telemetry = foldMetricEvent(telemetry, msg.event); if (transcript.sealedTurnId !== null) { void syncTail(); } diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts index 347cdd7..71781ac 100644 --- a/src/features/chat/store.test.ts +++ b/src/features/chat/store.test.ts @@ -393,52 +393,6 @@ describe("createChatStore", () => { store.dispose(); }); - it("folding step-complete and usage events populates telemetry", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - cache: cache.impl, - }); - - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta( - deltaEvent({ - type: "step-complete", - conversationId: CONV_ID, - turnId: "t1", - stepId: "t1#0" as StepId, - ttftMs: 300, - decodeMs: 700, - genTotalMs: 1000, - }), - ); - store.handleDelta( - deltaEvent({ - type: "usage", - conversationId: CONV_ID, - turnId: "t1", - stepId: "t1#0" as StepId, - usage: { inputTokens: 50, outputTokens: 20 }, - }), - ); - - const turn = store.telemetry.turns.get("t1"); - expect(turn).toBeDefined(); - expect(turn?.steps).toHaveLength(1); - const step = turn?.steps.find((s) => s.stepId === ("t1#0" as StepId)); - expect(step).toBeDefined(); - expect(step?.ttftMs).toBe(300); - expect(step?.decodeMs).toBe(700); - expect(step?.usage?.inputTokens).toBe(50); - expect(step?.usage?.outputTokens).toBe(20); - - store.dispose(); - }); - it("handleDelta ignores a chat.delta for a different conversationId", () => { const transport = createFakeTransport(); const historySync = createFakeHistorySync(); diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index 02d3c5a..b31cbf1 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -3,15 +3,9 @@ import { render, screen } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import type { RenderedChunk } from "../../core/chunks"; -import type { TelemetryState } from "../../core/telemetry"; -import { initialState } from "../../core/telemetry"; import ChatView from "./ui/ChatView.svelte"; import Composer from "./ui/Composer.svelte"; import ModelSelector from "./ui/ModelSelector.svelte"; -import TurnSummary from "./ui/TurnSummary.svelte"; - -const emptyTelemetry = initialState(); -const noTurnId = null; describe("ChatView", () => { it("renders a message's text chunk", () => { @@ -24,7 +18,7 @@ describe("ChatView", () => { }, ]; - render(ChatView, { props: { chunks, telemetry: emptyTelemetry, currentTurnId: noTurnId } }); + render(ChatView, { props: { chunks } }); expect(screen.getByText("Hello world")).toBeInTheDocument(); }); @@ -40,7 +34,7 @@ describe("ChatView", () => { }, ]; - render(ChatView, { props: { chunks, telemetry: emptyTelemetry, currentTurnId: noTurnId } }); + render(ChatView, { props: { chunks } }); expect(screen.getByText("Hi there")).toBeInTheDocument(); expect(screen.getByText("Hello!")).toBeInTheDocument(); @@ -61,7 +55,7 @@ describe("ChatView", () => { }, ]; - render(ChatView, { props: { chunks, telemetry: emptyTelemetry, currentTurnId: noTurnId } }); + render(ChatView, { props: { chunks } }); expect(screen.getByText("read_file")).toBeInTheDocument(); const pre = screen.getByText((content, element) => { @@ -86,7 +80,7 @@ describe("ChatView", () => { }, ]; - render(ChatView, { props: { chunks, telemetry: emptyTelemetry, currentTurnId: noTurnId } }); + render(ChatView, { props: { chunks } }); expect(screen.getByText("read_file")).toBeInTheDocument(); expect(screen.getByText("file contents here")).toBeInTheDocument(); @@ -102,7 +96,7 @@ describe("ChatView", () => { }, ]; - render(ChatView, { props: { chunks, telemetry: emptyTelemetry, currentTurnId: noTurnId } }); + render(ChatView, { props: { chunks } }); const alert = screen.getByRole("alert"); expect(alert).toHaveTextContent("Something failed"); @@ -118,7 +112,7 @@ describe("ChatView", () => { }, ]; - render(ChatView, { props: { chunks, telemetry: emptyTelemetry, currentTurnId: noTurnId } }); + render(ChatView, { props: { chunks } }); expect(screen.getByText("Rate limited")).toBeInTheDocument(); expect(screen.getByText("[RATE_LIMIT]")).toBeInTheDocument(); @@ -134,7 +128,7 @@ describe("ChatView", () => { }, ]; - render(ChatView, { props: { chunks, telemetry: emptyTelemetry, currentTurnId: noTurnId } }); + render(ChatView, { props: { chunks } }); expect(screen.getByText("System context loaded")).toBeInTheDocument(); }); @@ -149,7 +143,7 @@ describe("ChatView", () => { }, ]; - render(ChatView, { props: { chunks, telemetry: emptyTelemetry, currentTurnId: noTurnId } }); + render(ChatView, { props: { chunks } }); // In-flight chunks render at full opacity (no faded "disabled" look). const wrapper = screen.getByText("Streaming...").closest("div"); @@ -157,7 +151,7 @@ describe("ChatView", () => { }); it("renders empty transcript", () => { - render(ChatView, { props: { chunks: [], telemetry: emptyTelemetry, currentTurnId: noTurnId } }); + render(ChatView, { props: { chunks: [] } }); const log = screen.getByRole("log"); expect(log).toBeInTheDocument(); @@ -205,9 +199,7 @@ describe("ChatView", () => { }, ]; - const { container } = render(ChatView, { - props: { chunks, telemetry: emptyTelemetry, currentTurnId: noTurnId }, - }); + const { container } = render(ChatView, { props: { chunks } }); // One DaisyUI list with two rows (one per call), not separate cards. const lists = container.querySelectorAll("ul.list"); @@ -232,9 +224,7 @@ describe("ChatView", () => { }, ]; - const { container } = render(ChatView, { - props: { chunks, telemetry: emptyTelemetry, currentTurnId: noTurnId }, - }); + const { container } = render(ChatView, { props: { chunks } }); const collapse = container.querySelector(".collapse"); expect(collapse).not.toBeNull(); @@ -257,9 +247,7 @@ describe("ChatView", () => { }, ]; - const { container, rerender } = render(ChatView, { - props: { chunks: streaming, telemetry: emptyTelemetry, currentTurnId: noTurnId }, - }); + const { container, rerender } = render(ChatView, { props: { chunks: streaming } }); // Streaming: "Thinking" + loading dots. expect(screen.getByText("Thinking")).toBeInTheDocument(); @@ -281,8 +269,6 @@ describe("ChatView", () => { provisional: false, }, ], - telemetry: emptyTelemetry, - currentTurnId: noTurnId, }); // Completed: "Thoughts", no dots — and the open state survived the transition. @@ -292,118 +278,6 @@ describe("ChatView", () => { expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeChecked(); expect(container).toHaveTextContent("hmm, all done"); }); - - it("assistant text shows step metrics footer when step-complete data is available", () => { - const chunks: RenderedChunk[] = [ - { - seq: 1, - role: "assistant", - chunk: { type: "text", text: "Here is my answer" }, - provisional: false, - }, - ]; - - const telemetry: TelemetryState = { - turns: new Map([ - [ - "turn-1", - { - wallMs: 2500, - steps: [ - { - stepId: "turn-1#0" as StepId, - genTotalMs: 1200, - decodeMs: 1000, - usage: { inputTokens: 100, outputTokens: 86 }, - }, - ], - }, - ], - ]), - }; - - render(ChatView, { props: { chunks, telemetry, currentTurnId: "turn-1" } }); - - expect(screen.getByText("Here is my answer")).toBeInTheDocument(); - expect(screen.getByText("1.2s")).toBeInTheDocument(); - expect(screen.getByText("86 t/s")).toBeInTheDocument(); - expect(screen.getByText("86 tok")).toBeInTheDocument(); - }); - - it("does not show metrics footer when no step data exists", () => { - const chunks: RenderedChunk[] = [ - { - seq: 1, - role: "assistant", - chunk: { type: "text", text: "Still streaming" }, - provisional: true, - }, - ]; - - render(ChatView, { props: { chunks, telemetry: emptyTelemetry, currentTurnId: "turn-1" } }); - - expect(screen.getByText("Still streaming")).toBeInTheDocument(); - expect(screen.queryByText("t/s")).toBeNull(); - expect(screen.queryByText("tok")).toBeNull(); - }); -}); - -describe("TurnSummary", () => { - it("renders turn stats when telemetry has data", () => { - const telemetry: TelemetryState = { - turns: new Map([ - [ - "turn-1", - { - wallMs: 4200, - steps: [ - { - stepId: "turn-1#0" as StepId, - genTotalMs: 2000, - decodeMs: 1500, - usage: { inputTokens: 500, outputTokens: 300 }, - }, - { - stepId: "turn-1#1" as StepId, - genTotalMs: 1800, - decodeMs: 1200, - usage: { inputTokens: 600, outputTokens: 200 }, - }, - ], - }, - ], - ]), - }; - - render(TurnSummary, { props: { telemetry, turnId: "turn-1" } }); - - expect(screen.getByText("Turn")).toBeInTheDocument(); - expect(screen.getByText("4.2s")).toBeInTheDocument(); - expect(screen.getByText("Tokens")).toBeInTheDocument(); - expect(screen.getByText("1,600")).toBeInTheDocument(); - expect(screen.getByText("Output")).toBeInTheDocument(); - expect(screen.getByText("500")).toBeInTheDocument(); - expect(screen.getByText("Input")).toBeInTheDocument(); - expect(screen.getByText("1,100")).toBeInTheDocument(); - expect(screen.getByText("Steps")).toBeInTheDocument(); - expect(screen.getByText("2")).toBeInTheDocument(); - expect(screen.getByText("TPS")).toBeInTheDocument(); - expect(screen.getByText("185 t/s")).toBeInTheDocument(); - }); - - it("renders nothing when turnId is null", () => { - const { container } = render(TurnSummary, { - props: { telemetry: emptyTelemetry, turnId: null }, - }); - expect(container.querySelector(".stats")).toBeNull(); - }); - - it("renders nothing when turn metrics not found", () => { - const { container } = render(TurnSummary, { - props: { telemetry: emptyTelemetry, turnId: "nonexistent" }, - }); - expect(container.querySelector(".stats")).toBeNull(); - }); }); describe("Composer", () => { diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte index 6acda53..3a078fb 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -1,27 +1,16 @@ <script lang="ts"> import { groupRenderedChunks, type RenderedChunk } from "../index"; - import type { TelemetryState } from "../../../core/telemetry"; - import { stepMetrics, stepTps } from "../../../core/telemetry"; - interface Props { - chunks: readonly RenderedChunk[]; - telemetry: TelemetryState; - currentTurnId: string | null; - } - - let { chunks, telemetry, currentTurnId }: Props = $props(); + let { chunks }: { chunks: readonly RenderedChunk[] } = $props(); const groups = $derived(groupRenderedChunks(chunks)); - function formatMs(ms: number): string { - if (ms < 1000) return `${Math.round(ms)}ms`; - const s = ms / 1000; - return s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m${Math.round(s % 60)}s`; - } - + // Stable per-row keys. Thinking blocks get an ordinal key (`think<n>`) that + // survives the provisional→committed (seq null → seq N) transition, so the + // collapse's open/close state is NOT lost when a turn seals. (App isolates + // these keys per conversation via {#key}.) const rows = $derived.by(() => { let thinking = 0; - let stepIdx = 0; return groups.map((group, i) => { let key: string; if (group.kind === "tool-batch") { @@ -33,17 +22,14 @@ } else { key = `p${i}`; } - const si = stepIdx; - if (group.kind === "tool-batch" || (group.kind === "single" && (group.chunk.chunk.type === "tool-call" || group.chunk.chunk.type === "tool-result"))) { - stepIdx++; - } - return { group, key, stepIdx: si }; + return { group, key }; }); }); </script> -{#snippet chunkRow(rendered: RenderedChunk, sIdx: number)} +{#snippet chunkRow(rendered: RenderedChunk)} {#if rendered.role === "user"} + <!-- User: a speech bubble, left-aligned --> <div class="chat chat-start"> <div class="chat-bubble chat-bubble-primary"> {#if rendered.chunk.type === "text"} @@ -52,6 +38,9 @@ </div> </div> {:else if rendered.chunk.type === "thinking"} + <!-- Thinking: a visible bubble (like tool cards), holding a checkbox collapse + (no arrow icon, smooth open/close). Title reads "Thinking" + loading dots + while generating, then "Thoughts" with no dots once complete. --> <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> <div class="chat-bubble w-full bg-transparent"> <div class="collapse w-full rounded-box bg-base-200 text-sm"> @@ -69,18 +58,14 @@ </div> </div> {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"} - {@const step = currentTurnId ? stepMetrics(telemetry, currentTurnId, sIdx) : undefined} - {@const toolDur = step?.toolDurationMs} + <!-- Single tool call/result: a regular (non-speech) card. Nested in the + chat-start grid via a transparent, padding-stripped chat-bubble shim so + the card inherits the same left offset as the bubble bodies. --> <div class="chat chat-start [&>.chat-bubble]:max-w-full [&>.chat-bubble]:p-0"> <div class="chat-bubble bg-transparent"> {#if rendered.chunk.type === "tool-call"} <div class="w-fit max-w-full rounded-box bg-base-200 p-3 text-sm"> - <div class="flex items-center gap-2"> - <strong>{rendered.chunk.toolName}</strong> - {#if toolDur !== undefined && toolDur > 0} - <span class="badge badge-ghost badge-xs ml-auto">{formatMs(toolDur)}</span> - {/if} - </div> + <strong>{rendered.chunk.toolName}</strong> <pre class="text-xs mt-1">{JSON.stringify(rendered.chunk.input, null, 2)}</pre> </div> {:else} @@ -88,43 +73,19 @@ class="w-fit max-w-full rounded-box bg-base-200 p-3 text-sm" class:text-error={rendered.chunk.isError} > - <div class="flex items-center gap-2"> - <strong>{rendered.chunk.toolName}</strong> - {#if toolDur !== undefined && toolDur > 0} - <span class="badge badge-ghost badge-xs ml-auto">{formatMs(toolDur)}</span> - {/if} - </div> + <strong>{rendered.chunk.toolName}</strong> <pre class="text-xs mt-1">{rendered.chunk.content}</pre> </div> {/if} </div> </div> {:else} - {@const step = currentTurnId ? stepMetrics(telemetry, currentTurnId, sIdx) : undefined} - {@const tps = step ? stepTps(step) : undefined} + <!-- Assistant text / system / error: an INVISIBLE speech bubble — same + chat-start grid as the user bubble, so it inherits identical left spacing. --> <div class="chat chat-start [&>.chat-bubble]:max-w-5xl"> <div class="chat-bubble w-full bg-transparent"> {#if rendered.chunk.type === "text"} - <ul class="list rounded-box text-sm"> - <li class="list-row"> - <p>{rendered.chunk.text}</p> - </li> - {#if step && (step.genTotalMs !== undefined || tps !== undefined || step.usage?.outputTokens !== undefined)} - <li class="list-row"> - {#if step.genTotalMs !== undefined} - <span class="badge badge-ghost badge-xs">{formatMs(step.genTotalMs)}</span> - {/if} - <span>·</span> - {#if tps !== undefined} - <span class="badge badge-ghost badge-xs">{Math.round(tps)} t/s</span> - {/if} - <span>·</span> - {#if step.usage?.outputTokens !== undefined} - <span class="badge badge-ghost badge-xs">{step.usage.outputTokens} tok</span> - {/if} - </li> - {/if} - </ul> + <p>{rendered.chunk.text}</p> {:else if rendered.chunk.type === "error"} <div class="text-error" role="alert"> {rendered.chunk.message} @@ -141,24 +102,20 @@ {/snippet} <div class="flex flex-col gap-2 p-4 pl-6" role="log" aria-live="polite"> - {#each rows as { group, key, stepIdx } (key)} + {#each rows as { group, key } (key)} {#if group.kind === "single"} - {@render chunkRow(group.chunk, stepIdx)} + {@render chunkRow(group.chunk)} {:else} - {@const step = currentTurnId ? stepMetrics(telemetry, currentTurnId, stepIdx) : undefined} - {@const toolDur = step?.toolDurationMs} + <!-- Batched tool calls (one step): a single bubble holding a DaisyUI list, + one row per call paired with its result. Same chat-start grid shim as + the single tool card so it lines up with the other messages. --> <div class="chat chat-start [&>.chat-bubble]:max-w-full [&>.chat-bubble]:p-0"> <div class="chat-bubble bg-transparent"> <ul class="list w-fit max-w-full rounded-box bg-base-200 text-sm"> {#each group.entries as entry (entry.call.toolCallId)} <li class="list-row"> <div> - <div class="flex items-center gap-2"> - <strong>{entry.call.toolName}</strong> - {#if toolDur !== undefined && toolDur > 0} - <span class="badge badge-ghost badge-xs ml-auto">{formatMs(toolDur)}</span> - {/if} - </div> + <strong>{entry.call.toolName}</strong> <pre class="text-xs mt-1">{JSON.stringify(entry.call.input, null, 2)}</pre> {#if entry.result} <pre diff --git a/src/features/chat/ui/TurnSummary.svelte b/src/features/chat/ui/TurnSummary.svelte deleted file mode 100644 index eedb0cc..0000000 --- a/src/features/chat/ui/TurnSummary.svelte +++ /dev/null @@ -1,75 +0,0 @@ -<script lang="ts"> - import type { TelemetryState } from "../../../core/telemetry"; - import { - stepCount, - totalInputTokens, - totalOutputTokens, - turnMetrics, - turnTps, - } from "../../../core/telemetry"; - - interface Props { - telemetry: TelemetryState; - turnId: string | null; - } - - let { telemetry, turnId }: Props = $props(); - - function formatMs(ms: number): string { - if (ms < 1000) return `${Math.round(ms)}ms`; - const s = ms / 1000; - return s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m${Math.round(s % 60)}s`; - } - - const stats = $derived.by(() => { - if (turnId === null) return null; - const metrics = turnMetrics(telemetry, turnId); - if (metrics === undefined) return null; - - const items: { label: string; value: string }[] = []; - - if (metrics.wallMs !== undefined) { - items.push({ label: "Turn", value: formatMs(metrics.wallMs) }); - } - - const outTokens = totalOutputTokens(telemetry, turnId); - const inTokens = totalInputTokens(telemetry, turnId); - if (outTokens !== undefined || inTokens !== undefined) { - const total = (outTokens ?? 0) + (inTokens ?? 0); - items.push({ label: "Tokens", value: total.toLocaleString() }); - } - if (outTokens !== undefined) { - items.push({ label: "Output", value: outTokens.toLocaleString() }); - } - if (inTokens !== undefined) { - items.push({ label: "Input", value: inTokens.toLocaleString() }); - } - - const count = stepCount(telemetry, turnId); - if (count > 0) { - items.push({ label: "Steps", value: String(count) }); - } - - const tps = turnTps(telemetry, turnId); - if (tps !== undefined) { - items.push({ label: "TPS", value: `${Math.round(tps)} t/s` }); - } - - return items; - }); -</script> - -{#if stats !== null} - <div class="chat chat-start [&>.chat-bubble]:max-w-5xl"> - <div class="chat-bubble w-full bg-transparent"> - <div class="stats stats-vertical lg:stats-horizontal"> - {#each stats as stat} - <div class="stat"> - <div class="stat-title">{stat.label}</div> - <div class="stat-value text-sm">{stat.value}</div> - </div> - {/each} - </div> - </div> - </div> -{/if} |
